Update TypeScript and ESLint configurations for improved type safety and compatibility

- Modified ESLint configuration to support file patterns for TypeScript files.
- Updated TypeScript configurations across multiple applications to use ES2024 and enable composite builds.
- Refactored type inference in domain modules to utilize Zod's infer type for better type safety.
- Enhanced utility functions to handle various data types more robustly, improving overall code quality.
This commit is contained in:
barsa 2025-12-12 14:35:19 +09:00
parent 776cf3eeeb
commit ece89de49a
13 changed files with 143 additions and 157 deletions

View File

@ -5,6 +5,8 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"composite": true,
"tsBuildInfoFile": ".typecheck/tsconfig.tsbuildinfo",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"],

View File

@ -2,12 +2,14 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"jsx": "preserve", "jsx": "preserve",
"noEmit": true, "noEmit": true,
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"composite": true,
"tsBuildInfoFile": ".typecheck/tsconfig.tsbuildinfo",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]

View File

@ -38,7 +38,7 @@ export default [
// TypeScript recommended rules (fast, no type info) // TypeScript recommended rules (fast, no type info)
...tseslint.configs.recommended.map((config) => ({ ...tseslint.configs.recommended.map((config) => ({
...config, ...config,
files: ["**/*.ts", "**/*.tsx"], files: ["**/*.{ts,tsx}"],
languageOptions: { languageOptions: {
...(config.languageOptions || {}), ...(config.languageOptions || {}),
// Keep config simple: allow both environments; app-specific blocks can tighten later // Keep config simple: allow both environments; app-specific blocks can tighten later
@ -64,12 +64,6 @@ export default [
// Backend & domain packages // Backend & domain packages
{ {
files: ["apps/bff/**/*.ts", "packages/domain/**/*.ts"], files: ["apps/bff/**/*.ts", "packages/domain/**/*.ts"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: process.cwd(),
},
},
rules: { rules: {
"@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
@ -178,7 +172,12 @@ export default [
// Node globals for config files // Node globals for config files
{ {
files: ["*.config.mjs", "apps/portal/next.config.mjs"], files: [
"*.config.*",
"apps/portal/next.config.mjs",
"config/**/*.{js,cjs,mjs}",
"scripts/**/*.{js,cjs,mjs}",
],
languageOptions: { languageOptions: {
globals: { ...globals.node }, globals: { ...globals.node },
}, },

View File

@ -68,8 +68,8 @@ export const VALID_INVOICE_LIST_STATUSES = [
/** /**
* Check if a status string is valid for invoices * Check if a status string is valid for invoices
*/ */
export function isValidInvoiceStatus(status: string): boolean { export function isValidInvoiceStatus(status: string): status is ValidInvoiceStatus {
return VALID_INVOICE_STATUSES.includes(status as any); return (VALID_INVOICE_STATUSES as readonly string[]).includes(status);
} }
/** /**

View File

@ -1,5 +1,5 @@
import { z } from "zod"; import type { infer as ZodInfer } from "zod";
import { import type {
activityTypeSchema, activityTypeSchema,
activitySchema, activitySchema,
dashboardStatsSchema, dashboardStatsSchema,
@ -11,12 +11,12 @@ import {
dashboardSummaryResponseSchema, dashboardSummaryResponseSchema,
} from "./schema.js"; } from "./schema.js";
export type ActivityType = z.infer<typeof activityTypeSchema>; export type ActivityType = ZodInfer<typeof activityTypeSchema>;
export type Activity = z.infer<typeof activitySchema>; export type Activity = ZodInfer<typeof activitySchema>;
export type DashboardStats = z.infer<typeof dashboardStatsSchema>; export type DashboardStats = ZodInfer<typeof dashboardStatsSchema>;
export type NextInvoice = z.infer<typeof nextInvoiceSchema>; export type NextInvoice = ZodInfer<typeof nextInvoiceSchema>;
export type DashboardSummary = z.infer<typeof dashboardSummarySchema>; export type DashboardSummary = ZodInfer<typeof dashboardSummarySchema>;
export type DashboardError = z.infer<typeof dashboardErrorSchema>; export type DashboardError = ZodInfer<typeof dashboardErrorSchema>;
export type ActivityFilter = z.infer<typeof activityFilterSchema>; export type ActivityFilter = ZodInfer<typeof activityFilterSchema>;
export type ActivityFilterConfig = z.infer<typeof activityFilterConfigSchema>; export type ActivityFilterConfig = ZodInfer<typeof activityFilterConfigSchema>;
export type DashboardSummaryResponse = z.infer<typeof dashboardSummaryResponseSchema>; export type DashboardSummaryResponse = ZodInfer<typeof dashboardSummaryResponseSchema>;

View File

@ -1,11 +1,11 @@
/** /**
* Orders Domain - Validation * Orders Domain - Validation
* *
* Extended business validation rules for orders. * Extended business validation rules for orders.
* These rules represent domain logic and should be reusable across frontend/backend. * These rules represent domain logic and should be reusable across frontend/backend.
*/ */
import { z } from "zod"; import type { infer as ZodInfer } from "zod";
import { orderBusinessValidationSchema } from "./schema.js"; import { orderBusinessValidationSchema } from "./schema.js";
// ============================================================================ // ============================================================================
@ -18,7 +18,7 @@ import { orderBusinessValidationSchema } from "./schema.js";
*/ */
export function hasSimServicePlan(skus: string[]): boolean { export function hasSimServicePlan(skus: string[]): boolean {
return skus.some( return skus.some(
(sku) => sku =>
sku.toUpperCase().includes("SIM") && sku.toUpperCase().includes("SIM") &&
!sku.toUpperCase().includes("ACTIVATION") && !sku.toUpperCase().includes("ACTIVATION") &&
!sku.toUpperCase().includes("ADDON") !sku.toUpperCase().includes("ADDON")
@ -38,9 +38,7 @@ export function hasSimActivationFee(_skus: string[]): boolean {
*/ */
export function hasVpnActivationFee(skus: string[]): boolean { export function hasVpnActivationFee(skus: string[]): boolean {
return skus.some( return skus.some(
(sku) => sku => sku.toUpperCase().includes("VPN") && sku.toUpperCase().includes("ACTIVATION")
sku.toUpperCase().includes("VPN") &&
sku.toUpperCase().includes("ACTIVATION")
); );
} }
@ -50,7 +48,7 @@ export function hasVpnActivationFee(skus: string[]): boolean {
*/ */
export function hasInternetServicePlan(skus: string[]): boolean { export function hasInternetServicePlan(skus: string[]): boolean {
return skus.some( return skus.some(
(sku) => sku =>
sku.toUpperCase().includes("INTERNET") && sku.toUpperCase().includes("INTERNET") &&
!sku.toUpperCase().includes("INSTALL") && !sku.toUpperCase().includes("INSTALL") &&
!sku.toUpperCase().includes("ADDON") !sku.toUpperCase().includes("ADDON")
@ -62,7 +60,7 @@ export function hasInternetServicePlan(skus: string[]): boolean {
* (filters out installation, addons, activation fees) * (filters out installation, addons, activation fees)
*/ */
export function getMainServiceSkus(skus: string[]): string[] { export function getMainServiceSkus(skus: string[]): string[] {
return skus.filter((sku) => { return skus.filter(sku => {
const upperSku = sku.toUpperCase(); const upperSku = sku.toUpperCase();
return ( return (
!upperSku.includes("INSTALL") && !upperSku.includes("INSTALL") &&
@ -79,10 +77,10 @@ export function getMainServiceSkus(skus: string[]): string[] {
/** /**
* Complete order validation including all SKU business rules * Complete order validation including all SKU business rules
* *
* This schema delegates to the helper functions above for DRY validation. * This schema delegates to the helper functions above for DRY validation.
* Helper functions are reusable in both schema refinements and imperative validation. * Helper functions are reusable in both schema refinements and imperative validation.
* *
* Validates: * Validates:
* - Basic order structure (from orderBusinessValidationSchema) * - Basic order structure (from orderBusinessValidationSchema)
* - SIM orders have service plan + activation fee * - SIM orders have service plan + activation fee
@ -90,29 +88,20 @@ export function getMainServiceSkus(skus: string[]): string[] {
* - Internet orders have service plan * - Internet orders have service plan
*/ */
export const orderWithSkuValidationSchema = orderBusinessValidationSchema export const orderWithSkuValidationSchema = orderBusinessValidationSchema
.refine( .refine(data => data.orderType !== "SIM" || hasSimServicePlan(data.skus), {
(data) => data.orderType !== "SIM" || hasSimServicePlan(data.skus), message: "SIM orders must include a SIM service plan",
{ path: ["skus"],
message: "SIM orders must include a SIM service plan", })
path: ["skus"], .refine(data => data.orderType !== "VPN" || hasVpnActivationFee(data.skus), {
} message: "VPN orders require an activation fee",
) path: ["skus"],
.refine( })
(data) => data.orderType !== "VPN" || hasVpnActivationFee(data.skus), .refine(data => data.orderType !== "Internet" || hasInternetServicePlan(data.skus), {
{ message: "Internet orders require a service plan",
message: "VPN orders require an activation fee", path: ["skus"],
path: ["skus"], });
}
)
.refine(
(data) => data.orderType !== "Internet" || hasInternetServicePlan(data.skus),
{
message: "Internet orders require a service plan",
path: ["skus"],
}
);
export type OrderWithSkuValidation = z.infer<typeof orderWithSkuValidationSchema>; export type OrderWithSkuValidation = ZodInfer<typeof orderWithSkuValidationSchema>;
// ============================================================================ // ============================================================================
// Validation Error Messages // Validation Error Messages
@ -143,4 +132,3 @@ export function getOrderTypeValidationError(orderType: string, skus: string[]):
return null; return null;
} }

View File

@ -1,7 +1,7 @@
/** /**
* Shared WHMCS Provider Utilities * Shared WHMCS Provider Utilities
* Single source of truth for WHMCS data parsing * Single source of truth for WHMCS data parsing
* *
* Raw API types are source of truth - no fallbacks or variations expected. * Raw API types are source of truth - no fallbacks or variations expected.
*/ */
@ -12,7 +12,7 @@
export function parseAmount(amount: string | number | undefined): number { export function parseAmount(amount: string | number | undefined): number {
if (typeof amount === "number") return amount; if (typeof amount === "number") return amount;
if (!amount) return 0; if (!amount) return 0;
const cleaned = String(amount).replace(/[^\d.-]/g, ""); const cleaned = String(amount).replace(/[^\d.-]/g, "");
const parsed = Number.parseFloat(cleaned); const parsed = Number.parseFloat(cleaned);
return Number.isNaN(parsed) ? 0 : parsed; return Number.isNaN(parsed) ? 0 : parsed;
@ -24,10 +24,10 @@ export function parseAmount(amount: string | number | undefined): number {
*/ */
export function formatDate(input?: string | null): string | undefined { export function formatDate(input?: string | null): string | undefined {
if (!input) return undefined; if (!input) return undefined;
const date = new Date(input); const date = new Date(input);
if (Number.isNaN(date.getTime())) return undefined; if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString(); return date.toISOString();
} }
@ -55,7 +55,10 @@ export function normalizeCycle<T extends string>(
defaultCycle: T defaultCycle: T
): T { ): T {
if (!cycle) return defaultCycle; if (!cycle) return defaultCycle;
const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " "); const normalized = cycle
.trim()
.toLowerCase()
.replace(/[_\s-]+/g, " ");
return cycleMap[normalized] ?? defaultCycle; return cycleMap[normalized] ?? defaultCycle;
} }
@ -92,17 +95,22 @@ export function getCustomFieldsMap(customFields: unknown): Record<string, string
const map: Record<string, string> = {}; const map: Record<string, string> = {};
for (const entry of normalizeCustomFieldEntries(customFields)) { for (const entry of normalizeCustomFieldEntries(customFields)) {
const idRaw = "id" in entry ? entry.id : undefined;
const id = const id =
"id" in entry && entry.id !== undefined && entry.id !== null typeof idRaw === "string"
? String(entry.id).trim() ? idRaw.trim()
: undefined; : typeof idRaw === "number"
const name = ? String(idRaw)
"name" in entry && typeof entry.name === "string" : undefined;
? entry.name.trim() const name = "name" in entry && typeof entry.name === "string" ? entry.name.trim() : undefined;
: undefined;
const rawValue = "value" in entry ? entry.value : undefined; const rawValue = "value" in entry ? entry.value : undefined;
if (rawValue === undefined || rawValue === null) continue; if (rawValue === undefined || rawValue === null) continue;
const value = typeof rawValue === "string" ? rawValue : String(rawValue); const value =
typeof rawValue === "string"
? rawValue
: typeof rawValue === "number" || typeof rawValue === "boolean"
? String(rawValue)
: undefined;
if (!value) continue; if (!value) continue;
if (id) map[id] = value; if (id) map[id] = value;

View File

@ -64,12 +64,12 @@ function mapSimStatus(status: string | undefined): SimStatus {
function deriveSimType(sizeValue: unknown, eid?: string | number | null): SimType { function deriveSimType(sizeValue: unknown, eid?: string | number | null): SimType {
const simSizeStr = typeof sizeValue === "number" ? String(sizeValue) : sizeValue; const simSizeStr = typeof sizeValue === "number" ? String(sizeValue) : sizeValue;
const raw = typeof simSizeStr === "string" ? simSizeStr.toLowerCase() : undefined; const raw = typeof simSizeStr === "string" ? simSizeStr.toLowerCase() : undefined;
const eidStr = typeof eid === "number" ? String(eid) : eid; const eidStr = typeof eid === "number" ? String(eid) : eid;
if (eidStr && eidStr.length > 0) { if (eidStr && eidStr.length > 0) {
return "esim"; return "esim";
} }
switch (raw) { switch (raw) {
case "nano": case "nano":
return "nano"; return "nano";
@ -90,11 +90,12 @@ export function transformFreebitAccountDetails(raw: unknown): SimDetails {
} }
const sanitizedAccount = asString(account.account); const sanitizedAccount = asString(account.account);
const simSizeValue = account.simSize ?? (account as any).size; const legacySize = "size" in account ? (account as { size?: unknown }).size : undefined;
const simSizeValue = account.simSize ?? legacySize;
const eidValue = account.eid; const eidValue = account.eid;
const simType = deriveSimType( const simType = deriveSimType(
typeof simSizeValue === 'number' ? String(simSizeValue) : simSizeValue, typeof simSizeValue === "number" ? String(simSizeValue) : simSizeValue,
typeof eidValue === 'number' ? String(eidValue) : eidValue typeof eidValue === "number" ? String(eidValue) : eidValue
); );
const voiceMailEnabled = parseBooleanFlag(account.voicemail ?? account.voiceMail); const voiceMailEnabled = parseBooleanFlag(account.voicemail ?? account.voiceMail);
const callWaitingEnabled = parseBooleanFlag(account.callwaiting ?? account.callWaiting); const callWaitingEnabled = parseBooleanFlag(account.callwaiting ?? account.callWaiting);
@ -202,7 +203,9 @@ export function transformFreebitEsimAddAccountResponse(raw: unknown) {
return freebitEsimAddAccountRawSchema.parse(raw); return freebitEsimAddAccountRawSchema.parse(raw);
} }
export type FreebitEsimAddAccountResponse = ReturnType<typeof transformFreebitEsimAddAccountResponse>; export type FreebitEsimAddAccountResponse = ReturnType<
typeof transformFreebitEsimAddAccountResponse
>;
export function transformFreebitEsimActivationResponse(raw: unknown) { export function transformFreebitEsimActivationResponse(raw: unknown) {
return freebitEsimAddAccountRawSchema.parse(raw); return freebitEsimAddAccountRawSchema.parse(raw);

View File

@ -1,6 +1,6 @@
/** /**
* SIM Domain - Validation * SIM Domain - Validation
* *
* Business validation functions for SIM subscriptions. * Business validation functions for SIM subscriptions.
* These functions contain no infrastructure dependencies. * These functions contain no infrastructure dependencies.
*/ */
@ -9,33 +9,31 @@ import type { Subscription } from "../subscriptions/schema.js";
/** /**
* Check if a subscription is a SIM service * Check if a subscription is a SIM service
* *
* Business rule: A subscription is considered a SIM service if its product name * Business rule: A subscription is considered a SIM service if its product name
* or group name contains "SIM" (case-insensitive). * or group name contains "SIM" (case-insensitive).
* *
* @param subscription - The subscription to check * @param subscription - The subscription to check
* @returns true if this is a SIM subscription * @returns true if this is a SIM subscription
*/ */
export function isSimSubscription(subscription: Subscription): boolean { export function isSimSubscription(subscription: Subscription): boolean {
const productName = subscription.productName?.toLowerCase() || ""; const productName = subscription.productName?.toLowerCase() || "";
const groupName = subscription.groupName?.toLowerCase() || ""; const groupName = subscription.groupName?.toLowerCase() || "";
return productName.includes("sim") || groupName.includes("sim"); return productName.includes("sim") || groupName.includes("sim");
} }
/** /**
* Extract SIM account identifier from subscription data * Extract SIM account identifier from subscription data
* *
* The SIM account identifier (typically a phone number/MSISDN) can be stored * The SIM account identifier (typically a phone number/MSISDN) can be stored
* in various places depending on the subscription source and configuration. * in various places depending on the subscription source and configuration.
* This function tries multiple locations in priority order. * This function tries multiple locations in priority order.
* *
* @param subscription - The subscription to extract the account from * @param subscription - The subscription to extract the account from
* @returns The SIM account identifier (phone number), or null if not found * @returns The SIM account identifier (phone number), or null if not found
*/ */
export function extractSimAccountFromSubscription( export function extractSimAccountFromSubscription(subscription: Subscription): string | null {
subscription: Subscription
): string | null {
// 1. Try domain field first (most common location) // 1. Try domain field first (most common location)
if (subscription.domain && subscription.domain.trim()) { if (subscription.domain && subscription.domain.trim()) {
return subscription.domain.trim(); return subscription.domain.trim();
@ -60,16 +58,14 @@ export function extractSimAccountFromSubscription(
/** /**
* Extract account from custom fields using known field names * Extract account from custom fields using known field names
* *
* WHMCS and other billing systems may store the SIM phone number in various * WHMCS and other billing systems may store the SIM phone number in various
* custom field names. This function checks all known variations. * custom field names. This function checks all known variations.
* *
* @param customFields - The custom fields record to search * @param customFields - The custom fields record to search
* @returns The SIM account if found, null otherwise * @returns The SIM account if found, null otherwise
*/ */
function extractFromCustomFields( function extractFromCustomFields(customFields: Record<string, unknown>): string | null {
customFields: Record<string, unknown>
): string | null {
// Known field names for SIM phone numbers across different WHMCS configurations // Known field names for SIM phone numbers across different WHMCS configurations
const phoneFields = [ const phoneFields = [
// Standard field names // Standard field names
@ -79,7 +75,7 @@ function extractFromCustomFields(
"phone_number", "phone_number",
"mobile", "mobile",
"sim_phone", "sim_phone",
// Title case variations // Title case variations
"Phone Number", "Phone Number",
"MSISDN", "MSISDN",
@ -87,7 +83,7 @@ function extractFromCustomFields(
"Mobile", "Mobile",
"SIM Phone", "SIM Phone",
"PhoneNumber", "PhoneNumber",
// Additional variations // Additional variations
"mobile_number", "mobile_number",
"sim_number", "sim_number",
@ -97,7 +93,7 @@ function extractFromCustomFields(
"Phone Number (SIM)", "Phone Number (SIM)",
"Mobile Number", "Mobile Number",
"SIM Number", "SIM Number",
// Underscore variations // Underscore variations
"SIM_Number", "SIM_Number",
"SIM_Phone_Number", "SIM_Phone_Number",
@ -109,7 +105,15 @@ function extractFromCustomFields(
for (const fieldName of phoneFields) { for (const fieldName of phoneFields) {
const value = customFields[fieldName]; const value = customFields[fieldName];
if (value !== undefined && value !== null && value !== "") { if (value !== undefined && value !== null && value !== "") {
return String(value); if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) return trimmed;
continue;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
} }
} }
@ -118,14 +122,13 @@ function extractFromCustomFields(
/** /**
* Clean SIM account format * Clean SIM account format
* *
* Removes common formatting characters (hyphens, spaces, parentheses) * Removes common formatting characters (hyphens, spaces, parentheses)
* from phone numbers to get a clean numeric string. * from phone numbers to get a clean numeric string.
* *
* @param account - The account string to clean * @param account - The account string to clean
* @returns Cleaned account string with only digits * @returns Cleaned account string with only digits
*/ */
export function cleanSimAccount(account: string): string { export function cleanSimAccount(account: string): string {
return account.replace(/[-\s()]/g, ""); return account.replace(/[-\s()]/g, "");
} }

View File

@ -1,11 +1,20 @@
/** /**
* WHMCS Subscriptions Provider - Mapper * WHMCS Subscriptions Provider - Mapper
* *
* Transforms raw WHMCS product/service data into normalized subscription types. * Transforms raw WHMCS product/service data into normalized subscription types.
*/ */
import type { Subscription, SubscriptionStatus, SubscriptionCycle, SubscriptionList } from "../../contract.js"; import type {
import { subscriptionSchema, subscriptionListSchema, subscriptionStatusSchema } from "../../schema.js"; Subscription,
SubscriptionStatus,
SubscriptionCycle,
SubscriptionList,
} from "../../contract.js";
import {
subscriptionSchema,
subscriptionListSchema,
subscriptionStatusSchema,
} from "../../schema.js";
import { import {
type WhmcsProductRaw, type WhmcsProductRaw,
whmcsProductRawSchema, whmcsProductRawSchema,
@ -25,7 +34,7 @@ export interface TransformSubscriptionOptions {
} }
export interface TransformSubscriptionListResponseOptions extends TransformSubscriptionOptions { export interface TransformSubscriptionListResponseOptions extends TransformSubscriptionOptions {
status?: SubscriptionStatus | string; status?: string;
onItemError?: (error: unknown, product: WhmcsProductRaw) => void; onItemError?: (error: unknown, product: WhmcsProductRaw) => void;
} }
@ -108,10 +117,10 @@ export function transformWhmcsSubscription(
// Determine amount // Determine amount
const amount = parseAmount( const amount = parseAmount(
product.amount || product.amount ||
product.recurringamount || product.recurringamount ||
product.pricing?.amount || product.pricing?.amount ||
product.firstpaymentamount || product.firstpaymentamount ||
0 0
); );
// Transform to domain model // Transform to domain model
@ -203,7 +212,7 @@ export function transformWhmcsSubscriptionListResponse(
export function filterSubscriptionsByStatus( export function filterSubscriptionsByStatus(
list: SubscriptionList, list: SubscriptionList,
status: SubscriptionStatus | string status: string
): SubscriptionList { ): SubscriptionList {
const normalizedStatus = subscriptionStatusSchema.parse(status); const normalizedStatus = subscriptionStatusSchema.parse(status);
const filtered = list.subscriptions.filter(sub => sub.status === normalizedStatus); const filtered = list.subscriptions.filter(sub => sub.status === normalizedStatus);

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Type Helpers * Toolkit - Type Helpers
* *
* TypeScript utility types and helper functions. * TypeScript utility types and helper functions.
*/ */
@ -17,9 +17,7 @@ export type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
/** /**
* Deep partial (makes all nested properties optional) * Deep partial (makes all nested properties optional)
*/ */
export type DeepPartial<T> = T extends object export type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
/** /**
* Extract keys of a certain type * Extract keys of a certain type
@ -51,22 +49,25 @@ export type PickByValue<T, V> = Pick<
/** /**
* Safely access nested property * Safely access nested property
*/ */
export function getNestedProperty<T>( export function getNestedProperty<T>(obj: unknown, path: string, defaultValue?: T): T | undefined {
obj: unknown,
path: string,
defaultValue?: T
): T | undefined {
const keys = path.split("."); const keys = path.split(".");
let current: any = obj; let current: unknown = obj;
const isIndexableObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
for (const key of keys) { for (const key of keys) {
if (current === null || current === undefined || typeof current !== "object") { if (!isIndexableObject(current)) {
return defaultValue; return defaultValue;
} }
current = current[key]; current = current[key];
} }
return current ?? defaultValue; if (current === undefined || current === null) {
return defaultValue;
}
return current as T;
} }
// ============================================================================ // ============================================================================
@ -113,36 +114,27 @@ export function createErrorState<T, E = Error>(error: E): AsyncState<T, E> {
/** /**
* Type guard: check if state is idle * Type guard: check if state is idle
*/ */
export function isIdle<T, E>( export function isIdle<T, E>(state: AsyncState<T, E>): state is { status: "idle" } {
state: AsyncState<T, E>
): state is { status: "idle" } {
return state.status === "idle"; return state.status === "idle";
} }
/** /**
* Type guard: check if state is loading * Type guard: check if state is loading
*/ */
export function isLoading<T, E>( export function isLoading<T, E>(state: AsyncState<T, E>): state is { status: "loading" } {
state: AsyncState<T, E>
): state is { status: "loading" } {
return state.status === "loading"; return state.status === "loading";
} }
/** /**
* Type guard: check if state is success * Type guard: check if state is success
*/ */
export function isSuccess<T, E>( export function isSuccess<T, E>(state: AsyncState<T, E>): state is { status: "success"; data: T } {
state: AsyncState<T, E>
): state is { status: "success"; data: T } {
return state.status === "success"; return state.status === "success";
} }
/** /**
* Type guard: check if state is error * Type guard: check if state is error
*/ */
export function isError<T, E>( export function isError<T, E>(state: AsyncState<T, E>): state is { status: "error"; error: E } {
state: AsyncState<T, E>
): state is { status: "error"; error: E } {
return state.status === "error"; return state.status === "error";
} }

View File

@ -1,6 +1,6 @@
/** /**
* Domain Toolkit - Validation Helpers * Domain Toolkit - Validation Helpers
* *
* Common validation utilities that can be reused across all domains. * Common validation utilities that can be reused across all domains.
*/ */
@ -41,12 +41,7 @@ export function sanitizePagination(options: {
page: number; page: number;
limit: number; limit: number;
} { } {
const { const { page = 1, limit = options.defaultLimit ?? 10, minLimit = 1, maxLimit = 100 } = options;
page = 1,
limit = options.defaultLimit ?? 10,
minLimit = 1,
maxLimit = 100,
} = options;
return { return {
page: Math.max(1, Math.floor(page)), page: Math.max(1, Math.floor(page)),
@ -79,7 +74,7 @@ export function isValidEnumValue<T extends Record<string, string | number>>(
value: unknown, value: unknown,
enumObj: T enumObj: T
): value is T[keyof T] { ): value is T[keyof T] {
return Object.values(enumObj).includes(value as any); return Object.values(enumObj).some(enumValue => enumValue === value);
} }
// ============================================================================ // ============================================================================
@ -107,11 +102,7 @@ export function hasUniqueItems<T>(items: T[]): boolean {
/** /**
* Check if number is within range (inclusive) * Check if number is within range (inclusive)
*/ */
export function isInRange( export function isInRange(value: number, min: number, max: number): boolean {
value: number,
min: number,
max: number
): boolean {
return value >= min && value <= max; return value >= min && value <= max;
} }
@ -172,13 +163,7 @@ export function createPaginationSchema(options?: {
return z.object({ return z.object({
page: z.coerce.number().int().positive().optional().default(1), page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce limit: z.coerce.number().int().min(minLimit).max(maxLimit).optional().default(defaultLimit),
.number()
.int()
.min(minLimit)
.max(maxLimit)
.optional()
.default(defaultLimit),
offset: z.coerce.number().int().nonnegative().optional(), offset: z.coerce.number().int().nonnegative().optional(),
}); });
} }
@ -206,11 +191,6 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
* Type guard for checking if error is a Zod error * Type guard for checking if error is a Zod error
*/ */
export function isZodError(error: unknown): error is z.ZodError { export function isZodError(error: unknown): error is z.ZodError {
return ( if (!isRecord(error)) return false;
typeof error === "object" && return Array.isArray(error.issues);
error !== null &&
"issues" in error &&
Array.isArray((error as any).issues)
);
} }

View File

@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig", "$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": { "compilerOptions": {
"target": "ES2024", "target": "ES2024",
"lib": ["ESNext"], "lib": ["ES2024"],
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"strict": true, "strict": true,