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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cozy-wings-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@certes/composition": minor
---

Rewrite curry internals to increase execution performance
5 changes: 5 additions & 0 deletions .changeset/modern-days-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@certes/common": patch
---

Change types for lookup to use function overloads for better type inference
5 changes: 5 additions & 0 deletions .changeset/tough-planets-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@certes/composition": patch
---

Rewrite compose and pipe types to increase accuracy of type inference
1 change: 1 addition & 0 deletions packages/combinator/vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export default defineConfig({
clearMocks: true,
restoreMocks: true,
passWithNoTests: true,
silent: 'passed-only',
},
});
14 changes: 8 additions & 6 deletions packages/common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ Creates a curried lookup function with optional default handling for missing key

**Type Signature**
```typescript
// Without default function
function lookup(
obj: A,
def: B
): (prop: string | number | symbol) => ReturnType;
obj: T
): (prop: PropertyKey) => T[keyof T] | undefined;

// With default function
function lookup(
obj: A
): (prop: string | number | symbol) => A[keyof A] | undefined;
obj: T,
def: (value: T[keyof T] | undefined) => R
): (prop: PropertyKey) => T[keyof T] | R;
```

**Parameters**
Expand All @@ -58,7 +60,7 @@ type Statuses = typeof statusCodes[keyof typeof statusCodes];
const getStatus = lookup(statusCodes, (x: Statuses | undefined) => x ?? 'Unknown');

getStatus(200); // 'OK'
getStatus(999); // 'Unknown'
getStatus(999); // Statuses | 'Unknown'
```

---
Expand Down
98 changes: 70 additions & 28 deletions packages/common/src/lookup/index.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,93 @@
import { identity } from '@certes/combinator/i';

/**
* The value type of a record.
*/
type ValueOf<T> = T[keyof T];

/**
* Checks if a PropertyKey is a literal type (not the wide `string`, `number`, or `symbol`).
*/
type IsLiteralKey<K extends PropertyKey> = string extends K
? false
: number extends K
? false
: symbol extends K
? false
: true;

/**
* Creates a lookup function that retrieves values from a record with optional default handling.
*
* This is a curried function that first takes a lookup table and optional default handler,
* then returns a function that performs the actual property lookup.
*
* @template A - The record type used as the lookup table. Must extend Record<string | number | symbol, unknown>.
* @template B - The type of the default handler function. Must be a function that accepts unknown and returns unknown.
* @template T - The record type used as the lookup table.
*
* @param obj - The lookup table record.
* @param def - Optional function to handle missing or undefined values. Receives the looked-up value (possibly undefined) and returns a default. If not provided, uses identity function.
*
* @returns A lookup function that accepts a property key and returns the associated value or default.
* @param def - Optional function to handle values (including undefined for missing keys).
* Receives the looked-up value and returns a transformed result.
* If not provided, uses identity function.
*
* @remarks
* Pure function. The returned lookup function accepts `string | number | symbol` keys, allowing
* lookups of properties that may not exist in the type definition. When a key doesn't exist,
* the default function receives `undefined`.
*
* Type signature: `(Record<K, V>, (V | undefined -> W)?) -> (K -> W | V)`
* @returns A lookup function with intelligent return type narrowing:
* - Known literal key (`'FOO'`): exact type `T[K]`
* - Unknown literal key (`'MISSING'`): `R` (with default) or `undefined` (without)
* - Dynamic key (`string`): `ValueOf<T> | R` or `ValueOf<T> | undefined`
*
* @example
* ```ts
* const colorTable = {
* FOO: [0, 0, 255, 155],
* BAR: [255, 0, 255, 155],
* FIZZ: [230, 0, 0, 155],
* BUZZ: [0, 128, 0, 155],
* } as const;
*
* const colorLookup = lookup(colorTable, x => x ?? [128, 128, 128, 155]);
* const colorLookup = lookup(colorTable, () => [128, 128, 128, 155] as const);
*
* // Known literal key - narrows to exact type
* colorLookup('FOO'); // readonly [0, 0, 255, 155]
*
* // Unknown literal key - only the default
* colorLookup('MISSING'); // readonly [128, 128, 128, 155]
*
* // Dynamic key - union of all possibilities
* const key: string = getKey();
* colorLookup(key); // ValueOf<T> | readonly [128, 128, 128, 155]
*
* colorLookup('FOO'); // [0, 0, 255, 155]
* colorLookup('MISSING'); // [128, 128, 128, 155] (default)
* // Without default
* const directLookup = lookup(colorTable);
* directLookup('FOO'); // readonly [0, 0, 255, 155]
* directLookup('MISSING'); // undefined
* directLookup(key); // ValueOf<T> | undefined
* ```
*/
export const lookup =
<
A extends Record<string | number | symbol, unknown>,
// biome-ignore lint/suspicious/noExplicitAny: This is intended
B extends (...args: any[]) => any,
>(
obj: A,
def?: B,
) =>
<C extends keyof A>(prop: string | number | symbol): A[C] => {
const fn = def ?? identity;
const value = Object.hasOwn(obj, prop) ? obj[prop as keyof A] : undefined;
export function lookup<T extends Record<PropertyKey, unknown>>(
obj: T,
): (<K extends keyof T>(prop: K) => T[K]) &
(<K extends PropertyKey>(
prop: IsLiteralKey<K> extends true ? Exclude<K, keyof T> : never,
) => undefined) &
((prop: PropertyKey) => ValueOf<T> | undefined);

export function lookup<T extends Record<PropertyKey, unknown>, R>(
obj: T,
def: (value: ValueOf<T> | undefined) => R,
): (<K extends keyof T>(prop: K) => T[K]) &
(<K extends PropertyKey>(
prop: IsLiteralKey<K> extends true ? Exclude<K, keyof T> : never,
) => R) &
((prop: PropertyKey) => ValueOf<T> | R);

export function lookup<T extends Record<PropertyKey, unknown>, R>(
obj: T,
def?: (value: ValueOf<T> | undefined) => R,
): (prop: PropertyKey) => ValueOf<T> | undefined | R {
const fn = def ?? identity;

return (prop: PropertyKey): ValueOf<T> | undefined | R => {
const value = Object.hasOwn(obj, prop)
? (obj[prop as keyof T] as ValueOf<T>)
: undefined;

return fn(value);
};
}
196 changes: 196 additions & 0 deletions packages/common/src/lookup/lookup.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, expectTypeOf, it } from 'vitest';
import { lookup } from '.';

describe('Lookup', () => {
it('should narrow to exact type for known literal keys', () => {
const colorTable = {
FOO: [0, 0, 255, 155] as const,
BAR: [255, 0, 255, 155] as const,
FIZZ: [230, 0, 0, 155] as const,
} as const;

const directLookup = lookup(colorTable);
const withDefault = lookup(colorTable, () => [128, 128, 128, 155] as const);

// Known literal key - should narrow to exact type
expectTypeOf(directLookup('FOO')).toEqualTypeOf<
readonly [0, 0, 255, 155]
>();
expectTypeOf(directLookup('BAR')).toEqualTypeOf<
readonly [255, 0, 255, 155]
>();

expectTypeOf(withDefault('FOO')).toEqualTypeOf<readonly [0, 0, 255, 155]>();
expectTypeOf(withDefault('FIZZ')).toEqualTypeOf<
readonly [230, 0, 0, 155]
>();
});

it('should return undefined for unknown literal keys without default', () => {
const colorTable = {
FOO: [0, 0, 255, 155] as const,
BAR: [255, 0, 255, 155] as const,
} as const;

const directLookup = lookup(colorTable);

// Unknown literal key - should be undefined
expectTypeOf(directLookup('MISSING')).toEqualTypeOf<undefined>();
expectTypeOf(directLookup('NOPE')).toEqualTypeOf<undefined>();
});

it('should return default type for unknown literal keys with default', () => {
const colorTable = {
FOO: [0, 0, 255, 155] as const,
BAR: [255, 0, 255, 155] as const,
} as const;

const defaultColor = [128, 128, 128, 155] as const;
const withDefault = lookup(colorTable, () => defaultColor);

// Unknown literal key - should be the default type
expectTypeOf(withDefault('MISSING')).toEqualTypeOf<
readonly [128, 128, 128, 155]
>();
expectTypeOf(withDefault('NOPE')).toEqualTypeOf<
readonly [128, 128, 128, 155]
>();
});

it('should return union type for dynamic keys without default', () => {
const colorTable = {
FOO: [0, 0, 255, 155] as const,
BAR: [255, 0, 255, 155] as const,
} as const;

const directLookup = lookup(colorTable);

// Dynamic string key - should be union of all values | undefined
const dynamicKey: string = 'FOO';
expectTypeOf(directLookup(dynamicKey)).toEqualTypeOf<
readonly [0, 0, 255, 155] | readonly [255, 0, 255, 155] | undefined
>();
});

it('should return union type for dynamic keys with default', () => {
const colorTable = {
FOO: [0, 0, 255, 155] as const,
BAR: [255, 0, 255, 155] as const,
} as const;

const defaultColor = [128, 128, 128, 155] as const;
const withDefault = lookup(colorTable, () => defaultColor);

// Dynamic string key - should be union of all values | default type
const dynamicKey: string = 'FOO';
expectTypeOf(withDefault(dynamicKey)).toEqualTypeOf<
| readonly [0, 0, 255, 155]
| readonly [255, 0, 255, 155]
| readonly [128, 128, 128, 155]
>();
});

it('should handle numeric keys correctly', () => {
const statusCodes = {
200: 'OK' as const,
404: 'Not Found' as const,
500: 'Internal Server Error' as const,
} as const;

const getStatus = lookup(statusCodes, () => 'Unknown' as const);

// Known numeric literal
expectTypeOf(getStatus(200)).toEqualTypeOf<'OK'>();
expectTypeOf(getStatus(404)).toEqualTypeOf<'Not Found'>();

// Unknown numeric literal
expectTypeOf(getStatus(999)).toEqualTypeOf<'Unknown'>();

// Dynamic number
const code: number = 200;
expectTypeOf(getStatus(code)).toEqualTypeOf<
'OK' | 'Not Found' | 'Internal Server Error' | 'Unknown'
>();
});

it('should handle symbol keys correctly', () => {
const sym1 = Symbol('test');
const sym2 = Symbol('other');

const symbolTable = {
[sym1]: 'found' as const,
} as const;

const symLookup = lookup(symbolTable, () => 'not-found' as const);

// Known symbol literal
expectTypeOf(symLookup(sym1)).toEqualTypeOf<'found'>();

// Unknown symbol literal
expectTypeOf(symLookup(sym2 as typeof sym2)).toEqualTypeOf<'not-found'>();
});

it('should preserve const assertion types', () => {
const config = {
apiUrl: 'https://api.example.com' as const,
timeout: 5000 as const,
enabled: true as const,
} as const;

const getConfig = lookup(config);

// Each key should preserve its exact literal type
expectTypeOf(
getConfig('apiUrl'),
).toEqualTypeOf<'https://api.example.com'>();
expectTypeOf(getConfig('timeout')).toEqualTypeOf<5000>();
expectTypeOf(getConfig('enabled')).toEqualTypeOf<true>();
});

it('should handle default function with type transformation', () => {
const numberTable = {
one: 1,
two: 2,
three: 3,
} as const;

// Default function transforms undefined to string
const getLookup = lookup(numberTable, (x: 1 | 2 | 3 | undefined) =>
x === undefined ? 'unknown' : x.toString(),
);

// Known key should still return the original number type
expectTypeOf(getLookup('one')).toEqualTypeOf<1>();

// Unknown key should return the default type (string)
expectTypeOf(getLookup('four')).toEqualTypeOf<string>();

// Dynamic key should be union of both
const key: string = 'one';
expectTypeOf(getLookup(key)).toEqualTypeOf<1 | 2 | 3 | string>();
});

it('should handle mutable vs immutable tables', () => {
// Mutable table (no const assertion)
const mutableTable = {
foo: [1, 2, 3],
bar: [4, 5, 6],
};

const mutableLookup = lookup(mutableTable);

// Should return mutable array type
expectTypeOf(mutableLookup('foo')).toEqualTypeOf<number[]>();

// Immutable table (with const assertion)
const immutableTable = {
foo: [1, 2, 3] as const,
bar: [4, 5, 6] as const,
} as const;

const immutableLookup = lookup(immutableTable);

// Should return exact readonly tuple type
expectTypeOf(immutableLookup('foo')).toEqualTypeOf<readonly [1, 2, 3]>();
});
});
1 change: 1 addition & 0 deletions packages/common/vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export default defineConfig({
clearMocks: true,
restoreMocks: true,
passWithNoTests: true,
silent: 'passed-only',
},
});
Loading