diff --git a/packages/core/package.json b/packages/core/package.json index 4c638b6b..c3dfe18d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -51,11 +51,12 @@ "dependencies": { "tslog": "^3.3.3", "tsup": "^8.3.5", + "type-fest": "^4.34.1", "zod": "^3.24.1" }, "devDependencies": { - "@types/node": "^22.12.0", "@repo/typescript-config": "workspace:*", + "@types/node": "^22.12.0", "typescript": "^5.7.3" }, "scripts": { diff --git a/packages/core/src/pick.ts b/packages/core/src/pick.ts new file mode 100644 index 00000000..07d993aa --- /dev/null +++ b/packages/core/src/pick.ts @@ -0,0 +1,78 @@ +import { Split } from 'type-fest' + +/** + * Recursively pick the sub-object from T according to the array of keys in Keys. + * + * If Keys = ['address', 'coords', 'lat'], + * we want to produce { address: { coords: { lat: number } } } at the type level. + */ +type DeepPickArray = + Keys extends [] + ? T + : Keys extends [infer Head, ...infer Tail] + ? Head extends keyof T + ? { + [P in Head]: Tail extends string[] + ? DeepPickArray + : never + } + : never + : never; + +/** + * Given a single path string (e.g. "address/coords/lat") and a separator (default = "."), + * parse that path into an array via `Split`, then recursively pick the resulting type. + */ +type DeepPickOne< + T, + Path extends string, + Sep extends string = "." +> = DeepPickArray>; + +/** + * If DeepPickOne yields `never` (meaning invalid path), + * we allow a fallback type D (default = undefined). + */ +type DeepPickValue< + T, + Path extends string, + Sep extends string = "/", + D = undefined +> = DeepPickOne extends infer R + ? [R] extends [never] + ? D + : R + : D; + +export function pick < + T, + Path extends string, + Sep extends string = "/", + D = undefined +>( + obj: T, + path: Path, + options?: { + separator?: Sep; + defaultValue?: D; + } +): DeepPickValue { + // Determine separator (default to "/") + const separator = options?.separator ?? "/"; + // Split the path into keys + const keys = path.split(separator); + + // Traverse the object + let current: any = obj; + for (const key of keys) { + if (current == null || !(key in current)) { + // If we can't go further, return defaultValue (if provided) + return (options?.defaultValue as any) + ?? (undefined as DeepPickValue); + } + current = current[key]; + } + + // If we successfully traversed the entire path, return the value + return current as DeepPickValue; +}