diff --git a/packages/getters/src/utils/create-getter.test.ts b/packages/getters/src/utils/create-getter.test.ts index f60bb5c3d1..f1d4d47a8c 100644 --- a/packages/getters/src/utils/create-getter.test.ts +++ b/packages/getters/src/utils/create-getter.test.ts @@ -1,110 +1,205 @@ -import { assertType, describe, expect, it } from 'vitest'; +/* eslint-disable no-restricted-syntax */ + +import { describe, expect, expectTypeOf, it, test } from 'vitest'; import { createGetter } from './create-getter.js'; +import { Getter } from './getter.js'; + +type City = 'Seattle' | 'San Francisco' | 'New York City'; interface Address { - city: string; - state: string; - country?: string; + city: City; + state?: string; } -interface Employee { +interface Person { firstName: string; lastName?: string; address?: Address; } -const employee: Employee = { +const alice: Person = { firstName: 'Alice', + lastName: 'Liddell', address: { city: 'San Francisco', state: 'California', }, }; -const getFirstName = createGetter((employee?: Employee) => employee?.firstName); -const getLastName = createGetter((employee?: Employee) => employee?.lastName); -const getAddress = createGetter((employee?: Employee) => employee?.address); -const getCity = createGetter((address?: Address) => address?.city); -const getCountry = createGetter((address?: Address) => address?.country); -const getFirstLetter = createGetter((value?: string) => value?.[0]); +const bob: Person = { + firstName: 'Bob', + address: { + city: 'Seattle', + }, +}; + +const charlie: Person = { + firstName: 'Charlie', + lastName: '', +}; describe('createGetter()', () => { - describe('getter()', () => { - it('gets the value via the function passed into `createGetter()`', () => { - expect(getFirstName(employee)).toBe('Alice'); - }); + const selectFirstName = (p?: Person) => p?.firstName; + const selectStringIndexOne = (a?: string) => a?.[1]; + + it('creates a getter', () => { + let getFirstNameFromPerson; + expectTypeOf((getFirstNameFromPerson = createGetter(selectFirstName))).toEqualTypeOf< + Getter + >(); + expect(getFirstNameFromPerson).toBeInstanceOf(Function); + expect(getFirstNameFromPerson).toHaveProperty(['optional', 'pipe']); + }); - it('throws when the whole value is undefined', () => { + it('creates a getter with optional', () => { + let getFirstNameFromPerson_optional; + expectTypeOf( + (getFirstNameFromPerson_optional = createGetter(selectFirstName).optional), + ).toEqualTypeOf>(); + + expect(getFirstNameFromPerson_optional).toBeInstanceOf(Function); + expect(getFirstNameFromPerson_optional).toHaveProperty('optional'); + expect(getFirstNameFromPerson_optional).toHaveProperty('pipe'); + + expect(getFirstNameFromPerson_optional.optional).toBe(getFirstNameFromPerson_optional); + }); + + it('creates a getter pipe', () => { + const getFirstName = createGetter(selectFirstName); + const getSecondLetter = createGetter(selectStringIndexOne); + + let getSecondLetterOfFirstNameFromPerson; + expectTypeOf( + (getSecondLetterOfFirstNameFromPerson = getFirstName.pipe(getSecondLetter)), + ).toEqualTypeOf>(); + + expect(getSecondLetterOfFirstNameFromPerson).toBeInstanceOf(Function); + expect(getSecondLetterOfFirstNameFromPerson).toHaveProperty('optional'); + expect(getSecondLetterOfFirstNameFromPerson).toHaveProperty('pipe'); + }); +}); + +describe('getting values and optional', () => { + const getFirstName = createGetter((p?: Person) => p?.firstName); + const getLastName = createGetter((p?: Person) => p?.lastName); + + it('gets the expected value', () => { + expect(getFirstName(alice)).toBe('Alice'); + expect(getFirstName.optional(alice)).toBe('Alice'); + + expect(getLastName(alice)).toBe('Liddell'); + expect(getLastName.optional(alice)).toBe('Liddell'); + }); + + describe('undefined in the getter', () => { + it('handles undefined input', () => { expect(() => getFirstName(undefined)).toThrow(); + expect(getFirstName.optional(undefined)).toBeUndefined(); }); - it('throws for an undefined property', () => { - expect(() => getLastName(employee)).toThrow(); + it('handles undefined property', () => { + expect(() => getLastName(bob)).toThrow(); + expect(getLastName.optional(bob)).toBeUndefined(); }); + }); - it('does not throw if a value is falsey but not undefined', () => { - const employee: Employee = { firstName: 'Alice', lastName: '' }; - expect(() => getLastName(employee)).not.toThrow(); - }); + test('successfully returns a falsy value', () => { + expect(() => getLastName(charlie)).not.toThrow(); }); +}); - describe('getter.optional()', () => { - it('returns `undefined` when the whole value is undefined', () => { - expect(getLastName.optional()(undefined)).toBeUndefined(); - }); +describe('getter pipes', () => { + const selectAddress = (p?: Person) => p?.address; + const getAddressFromPerson = createGetter(selectAddress); + const selectCity = (a?: Address) => a?.city; + const getCityFromAddress = createGetter(selectCity); + const getStateFromAddress = createGetter((a?: Address) => a?.state); - it('returns `undefined` for an undefined property', () => { - expect(getLastName.optional()(employee)).toBeUndefined(); - }); + it('pipes the getters together and returns the final result', () => { + let getCityFromPerson; + + expectTypeOf((getCityFromPerson = getAddressFromPerson.pipe(getCityFromAddress))).toEqualTypeOf< + Getter + >(); + + expect(getCityFromPerson(alice)).toBe('San Francisco'); + expect(getCityFromPerson(bob)).toBe('Seattle'); }); - describe('getter.pipe()', () => { - it('pipes the getters together and returns the final result', () => { - expect(getAddress.pipe(getCity)(employee)).toBe('San Francisco'); - }); + describe('undefined in the pipe', () => { + let getStateFromPerson; + + expectTypeOf( + (getStateFromPerson = getAddressFromPerson.pipe(getStateFromAddress)), + ).toEqualTypeOf>(); - it('throws when any value in the property chain is undefined', () => { - expect(() => getAddress.pipe(getCity)(undefined)).toThrow(); - expect(() => getAddress.pipe(getCity)({ firstName: 'Alice' })).toThrow(); - expect(() => getAddress.pipe(getCountry)(employee)).toThrow(); + it('throws on undefined', () => { + expect(() => getStateFromPerson(undefined)).toThrow(); + expect(getStateFromPerson(alice)).toBe('California'); + expect(() => getStateFromPerson(bob)).toThrow(); }); - describe('getter.pipe() with .optional())', () => { - const employee: Employee = { - firstName: 'Alice', - address: { - city: '', // `getFirstLetter` will return undefined - state: 'California', - }, - }; - - it('does not throw when the first getter is used with `.optional()` and some value in the chain is undefined', () => { - expect(() => - getAddress.optional().pipe(getCity).pipe(getFirstLetter)(employee), - ).not.toThrow(); - }); - - it('does not throw when a later getter is used with `.optional()` and some value in the chain is undefined', () => { - const baseGetter = getAddress.pipe(getCity).pipe(getFirstLetter); - - // Before testing that it _doesn't_ throw with `.optional()`, first make - // sure that it _does_ throw without it, to ensure that this test is - // valid. - expect(() => baseGetter(employee)).toThrow(); - expect(() => baseGetter.optional()(employee)).not.toThrow(); - }); - - it('does throw when used without `.optional()` and some value in the chain is undefined', () => { - expect(() => getAddress.pipe(getCity).pipe(getFirstLetter)(employee)).toThrow(); - }); + it("doesn't throw on undefined when optional", () => { + expect(getStateFromPerson.optional(undefined)).toBeUndefined(); + expect(getStateFromPerson.optional(alice)).toBe('California'); + expect(getStateFromPerson.optional(bob)).toBeUndefined(); }); }); - // Type assertions - these will be run at build time, rather than at test - // time. - assertType(getAddress.pipe(getCity)(employee)); - // @ts-expect-error - Assert that `string` on its own is incorrect for an - // optional getter -- it should be `string | undefined`. - assertType(getAddress.pipe(getCity).optional()(employee)); - assertType(getAddress.pipe(getCity).optional()(employee)); + describe('longer chains', () => { + const getSecondLetter = createGetter((s?: string) => s?.[1]); + it('applies optional to the chain', () => { + expect(getAddressFromPerson.pipe(getStateFromAddress).pipe(getSecondLetter)(alice)).toBe('a'); + expect(() => + getAddressFromPerson.pipe(getStateFromAddress).pipe(getSecondLetter)(bob), + ).toThrow(); + expect(() => + getAddressFromPerson.pipe(getStateFromAddress).pipe(getSecondLetter)(charlie), + ).toThrow(); + + const lastOptionalStateLetter = getAddressFromPerson // address is required + .pipe(getStateFromAddress) // state is required + .pipe(getSecondLetter).optional; // letter is optional, turning the whole chain optional + expect(lastOptionalStateLetter(alice)).toBe('a'); + expect(lastOptionalStateLetter(bob)).toBeUndefined(); + expect(lastOptionalStateLetter(charlie)).toBeUndefined(); + + const lastOptionalCityLetter = getAddressFromPerson // address is required + .pipe(getCityFromAddress) // city is required + .pipe(getSecondLetter).optional; // letter is optional, turning the whole chain optional + expect(lastOptionalCityLetter(alice)).toBe('a'); + expect(lastOptionalCityLetter(bob)).toBe('e'); + expect(lastOptionalCityLetter(charlie)).toBeUndefined(); + + const midOptionalStateLetter = getAddressFromPerson // address is required + .pipe(getStateFromAddress) // state is optional, turning the whole chain optional + .optional.pipe(getSecondLetter); // letter is required + expect(midOptionalStateLetter(alice)).toBe('a'); + expect(midOptionalStateLetter(bob)).toBeUndefined(); + expect(midOptionalStateLetter(charlie)).toBeUndefined(); + + const midOptionalCityLetter = getAddressFromPerson // address is required + .pipe(getCityFromAddress) // city is optional, turning the whole chain optional + .optional.pipe(getSecondLetter); // letter is required + expect(midOptionalCityLetter(alice)).toBe('a'); + expect(midOptionalCityLetter(bob)).toBe('e'); + expect(midOptionalCityLetter(charlie)).toBeUndefined(); + + // state is required + const firstOptionalStateLetter = getAddressFromPerson.optional // address is optional, turning the whole chain optional + .pipe(getStateFromAddress) // state is required + .pipe(getSecondLetter); // letter is required + expect(firstOptionalStateLetter(alice)).toBe('a'); + expect(firstOptionalStateLetter(bob)).toBeUndefined(); + expect(firstOptionalStateLetter(charlie)).toBeUndefined(); + + // city is required + const firstOptionalCityLetter = getAddressFromPerson.optional // address is optional, turning the whole chain optional + .pipe(getCityFromAddress) // city is required + .pipe(getSecondLetter); // letter is required + expect(firstOptionalCityLetter(alice)).toBe('a'); + expect(firstOptionalCityLetter(bob)).toBe('e'); + expect(firstOptionalCityLetter(charlie)).toBeUndefined(); + }); + }); }); diff --git a/packages/getters/src/utils/create-getter.ts b/packages/getters/src/utils/create-getter.ts index 7a87637e08..5172ede724 100644 --- a/packages/getters/src/utils/create-getter.ts +++ b/packages/getters/src/utils/create-getter.ts @@ -1,125 +1,74 @@ -/** - * This error will be thrown when a getter that hasn't been marked `.optional()` - * returns `undefined`. You can import this error class in your code to - * differentiate between this specific type of error and others. (If you want to - * catch this error just to make a getter optional, though, it's easier to just - * call `.optional()` on the getter first: - * `getAddressIndex.optional()(addressView)`.) - */ -export class GetterMissingValueError extends Error {} +import { Getter } from './getter.js'; +import { GetterMissingValueError } from './getter-missing-value-error.js'; -export interface Getter { - /** - * Given an input value of `SourceType`, asserts successful retrieval of a - * value of `TargetType`, by the naive retrieval function passed to - * `createGetter`. - * - * If undefined access occurs while retrieving `TargetType`, a - * `GetterMissingValueError` is thrown. - */ - (value?: SourceType): TargetType; - - /** - * Returns a getter of type `Getter` - * that will avoid throwing if the property is not available. - * - * @example - * ```ts - * const getMetadataFromValueView = createGetter(valueView => - * valueView?.valueView.case === 'knownAssetId' ? valueView.valueView.value.metadata : undefined, - * ); - * - * // Note that `valueView` has no metadata, nor even a `case`. - * const valueView = new ValueView(); - * - * // Doesn't throw, even though the metadata is missing. - * const metadata = getMetadataFromValueView.optional()(valueView); - * ``` - */ - optional: () => Getter; - - /** - * Pipes the output of this getter to another getter or getter function. - * - * @example - * ```ts - * // Gets the deeply nested `inner` property in a metadata object, or throws - * // if any step in the pipe is undefined. - * const assetId1 = getMetadata.pipe(getAssetId).pipe(getInner)(valueView); - * // Gets the deeply nested `inner` property in a metadata object, or returns - * // undefined if any step in the pipe is undefined. Note that `.optional()` - * // must be called at the _beginning_ of the chain. - * const assetId2 = getMetadata.optional().pipe(getAssetId).pipe(getInner)(valueView); - * ``` - */ - pipe: ( - pipeSelector: - | Getter - | ((value?: TargetType | NonNullable | undefined) => PipeTargetType), - ) => Getter< - SourceType, - TargetType extends undefined ? PipeTargetType | undefined : PipeTargetType - >; -} +const createPiper = + (firstSelector: (s?: PipeSourceType) => IntermediateType) => + ( + secondSelector: (i?: IntermediateType) => PipeTargetType, + ): Getter => { + const pipedFn = (source?: PipeSourceType) => { + const intermediate: IntermediateType = firstSelector(source); + const target: PipeTargetType = secondSelector(intermediate); + return target; + }; + + const pipedGetter = Object.defineProperties(pipedFn, { + pipe: { + enumerable: true, + get() { + return createPiper(pipedFn); + }, + }, + optional: { + enumerable: true, + get() { + return createOptional(pipedFn); + }, + }, + required: { + enumerable: true, + get() { + return createRequired(pipedFn); + }, + }, + }) as Getter; -const requiredValueFn = (fn: (v?: S) => T) => { - return (v?: S) => { - const r = fn(v); - if (r != null) { - return r; - } - throw new GetterMissingValueError(`Failed to extract from ${JSON.stringify(v)}`); + return pipedGetter; }; -}; -const possibleValueFn = (fn: (v?: S) => T) => { - return (v?: S) => { +const createOptional = ( + selector: (v?: SourceType) => TargetType | undefined, +): Getter => { + const optionalFn = (source?: SourceType) => { try { - return fn(v); + return selector(source); } catch (e) { if (e instanceof GetterMissingValueError) { - return; + return undefined; } throw e; } }; -}; -const pipedValueFn = - (inFn: (sv?: S) => I, outFn: (iv?: I) => T) => - (v?: S) => - outFn(inFn(v)); - -const createPiper = - (selector: (sv?: PipeSourceType) => IntermediateType) => - (pipeSelector: (iv?: IntermediateType) => PipeTargetType) => { - const pipedFn = pipedValueFn(selector, pipeSelector); - - const pipedGetter = Object.assign(pipedFn, { - optional() { - return createOptional(pipedFn); + const optionalGetter = Object.defineProperties(optionalFn, { + pipe: { + enumerable: true, + value: (nextSelector: (i?: TargetType) => NextTargetType) => { + return createPiper(optionalFn)(nextSelector).optional; }, - - pipe(nextPipeSelector: (tv?: PipeTargetType) => NextPipeTargetType) { - return createPiper(pipedFn)(nextPipeSelector); + }, + required: { + enumerable: true, + get() { + return createRequired(selector); }, - }); - - return pipedGetter; - }; - -const createOptional = ( - selector: (v?: SourceType) => TargetType | undefined, -): Getter => { - const optionalFn = possibleValueFn(selector); - - const optionalGetter = Object.assign(optionalFn, { - optional() { - return optionalGetter; }, + }) as Getter; - pipe(pipeSelector: (tv?: TargetType | undefined) => PipeTargetType) { - return createPiper(optionalFn)(pipeSelector).optional(); + Object.defineProperty(optionalGetter, 'optional', { + enumerable: true, + get() { + return optionalGetter; }, }); @@ -129,21 +78,40 @@ const createOptional = ( const createRequired = ( selector: (v?: SourceType) => TargetType | undefined, ): Getter> => { - const requiredFn = requiredValueFn(selector); + const requiredFn = (source?: SourceType) => { + const required = selector(source); + if (required == null) { + throw new GetterMissingValueError( + `Failed to select value from "${String(source)}" with "${selector.name}"`, + { cause: { source, selector } }, + ); + } + return required; + }; - const requiredGetter = Object.assign(requiredFn, { - optional() { - return createOptional | undefined>(requiredFn); + const requiredGetter = Object.defineProperties(requiredFn, { + pipe: { + enumerable: true, + get() { + return createPiper(requiredFn); + }, + }, + optional: { + enumerable: true, + get() { + return createOptional(selector); + }, }, + }) as Getter>; - pipe( - pipeSelector: (tv?: NonNullable) => PipeTargetType, - ): Getter { - return createPiper(requiredFn)(pipeSelector); + Object.defineProperty(requiredGetter, 'required', { + enumerable: true, + get() { + return requiredGetter; }, }); return requiredGetter; }; -export const createGetter = createRequired; +export { createRequired as createGetter }; diff --git a/packages/getters/src/utils/getter-missing-value-error.ts b/packages/getters/src/utils/getter-missing-value-error.ts new file mode 100644 index 0000000000..77a94480a3 --- /dev/null +++ b/packages/getters/src/utils/getter-missing-value-error.ts @@ -0,0 +1,11 @@ +/** + * This error will be thrown when a getter that isn't called `optional` returns + * `undefined`. You can import this error class in your code to differentiate + * between this specific type of error and others. + * + * If you want to catch this error just to suppress it, it's easier to just call + * the getter as `optional` instead. + * + * `getAddressIndex.optional(addressView)`.) + */ +export class GetterMissingValueError extends Error {} diff --git a/packages/getters/src/utils/getter.ts b/packages/getters/src/utils/getter.ts new file mode 100644 index 0000000000..6f630dde37 --- /dev/null +++ b/packages/getters/src/utils/getter.ts @@ -0,0 +1,64 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface Getter { + /** + * Given an input value of `SourceType`, asserts successful retrieval of a + * value of `TargetType`, by the naive retrieval function passed to + * `createGetter`. + * + * If undefined access occurs while retrieving `TargetType`, a + * `GetterMissingValueError` is thrown. + */ + (value?: SourceType): TargetType; + + /** + * Every getter contains an `optional` getter, which adds `undefined` to the + * target type, and which will not throw `GetterMissingValueError`. + * + * @example + * ```ts + * const getMetadataFromValueView = createGetter(valueView => + * valueView?.valueView.case === 'knownAssetId' ? valueView.valueView.value.metadata : undefined, + * ); + * + * // Note that this `emptyValueView` has no metadata, nor even a `case`. + * const emptyValueView = new ValueView(); + * + * // Doesn't throw, even though the metadata is missing. + * const noMetadata: Metadata | undefined = getMetadataFromValueView.optional(emptyValueView); + * ``` + */ + readonly optional: Getter; + + /** + * Every getter contains a `required` getter, which wraps `NonNullable` around + * the target type, and which will throw `GetterMissingValueError` if the + * accessed result is `undefined`. + */ + readonly required: Getter>; + + /** + * Call `pipe` to create a getter for the return type of another getter or + * selector function provided as a parameter. Your parameter function must + * accept the output of this getter as its 0th input parameter. If the return + * type of your pipe parameter includes `undefined`, the return type of the + * created getter will also include undefined (it will be an optional getter). + * + * @example + * ```ts + * // Gets the deeply nested `inner` property in a metadata object, or throws + * // if any step in the pipe is undefined. + * const assetId1 = getMetadata.pipe(getAssetId).pipe(getInner)(valueView); + * // Gets the deeply nested `inner` property in a metadata object, or returns + * // `undefined`. + * const assetId2 = getMetadata.optional.pipe(getAssetId).pipe(getInner)(valueView); + * ``` + */ + readonly pipe: ( + pipeSelector: + | Getter + | ((value?: TargetType | NonNullable | undefined) => PipeTargetType), + ) => Getter< + SourceType, + TargetType extends undefined ? PipeTargetType | undefined : PipeTargetType + >; +}