diff --git a/src/types/mapper.spec-d.ts b/src/types/mapper.spec-d.ts new file mode 100644 index 0000000..2562f6e --- /dev/null +++ b/src/types/mapper.spec-d.ts @@ -0,0 +1,48 @@ +import { expectTypeOf, test } from 'vitest' +import { Mapper } from '@/types/mapper' +import { Profile } from '@/types/profile' + +declare const broadSourceMapper: Mapper<[ + Profile, + Profile<'a', string, 'y', boolean> +]> + +declare const broadSourceKey: string + +test('broad source keys do not expose destinations from unrelated profiles', () => { + // @ts-expect-error Only source a has destination y. + broadSourceMapper.map('b', 'hello', 'y') + // @ts-expect-error Only source a has destination y. + broadSourceMapper.mapMany('b', ['hello'], 'y') + + expectTypeOf(broadSourceMapper.map('a', 'hello', 'y')).toEqualTypeOf() + expectTypeOf(broadSourceMapper.mapMany('a', ['hello'], 'y')).toEqualTypeOf() + expectTypeOf(broadSourceMapper.map(broadSourceKey, 1, 'x')).toEqualTypeOf() + expectTypeOf(broadSourceMapper.mapMany(broadSourceKey, [1], 'x')).toEqualTypeOf() + + // @ts-expect-error Destination y expects a string input. + broadSourceMapper.map('a', 1, 'y') + // @ts-expect-error Destination y expects string inputs. + broadSourceMapper.mapMany('a', [1], 'y') +}) + +declare const literalMapper: Mapper<[ + Profile<'a', number, 'x', string>, + Profile<'a', string, 'y', Promise>, + Profile<'b', number, 'x', boolean> +]> + +declare const unionSourceKey: 'a' | 'b' + +test('literal and union keys retain their mapping results', () => { + expectTypeOf(literalMapper.map('a', 1, 'x')).toEqualTypeOf() + expectTypeOf(literalMapper.map('a', 'hello', 'y')).toEqualTypeOf>() + expectTypeOf(literalMapper.mapMany('a', ['hello'], 'y')).toEqualTypeOf[]>() + expectTypeOf(literalMapper.map(unionSourceKey, 1, 'x')).toEqualTypeOf() + expectTypeOf(literalMapper.mapMany(unionSourceKey, [1], 'x')).toEqualTypeOf<(string | boolean)[]>() + + // @ts-expect-error Source b has no destination y. + literalMapper.map('b', 'hello', 'y') + // @ts-expect-error Destination x expects a number input. + literalMapper.map('a', 'hello', 'x') +}) diff --git a/src/types/mapper.ts b/src/types/mapper.ts index 1fb5786..37465a2 100644 --- a/src/types/mapper.ts +++ b/src/types/mapper.ts @@ -1,8 +1,7 @@ import { ExtractSourceKeys, Profile } from '@/types/profile' /** - * Registered profiles grouped once by source key, then destination key, so each `map` call is two - * property lookups rather than a fresh pass over every registered profile. + * Type-level index of profiles by source and destination key, reused when checking mapping calls. */ type ProfileIndex = { [TSourceKey in ExtractSourceKeys]: { @@ -10,11 +9,14 @@ type ProfileIndex = { } } +// A broad source key can collapse index entries together. Check the selected source key +// before extracting types so unrelated profiles cannot become valid matches. + /** Extracts the source type from a profile. `unknown` for the destination parameter matches any profile regardless of its destination type. */ -type ProfileSource = TProfile extends Profile ? TSource : never +type ProfileSource = TProfile extends Profile ? TSource : never -/** Extracts the destination type from a profile. `never` for the source parameter exploits contravariance — any concrete source type satisfies `never extends TSource` in the function position, so the match is unconditional. */ -type ProfileDestination = TProfile extends Profile ? TDestination : never +/** Extracts the destination type from a profile. `never` for the source parameter exploits contravariance — any concrete source type satisfies `never extends TSource` in the function position, so the source type does not restrict the match. */ +type ProfileDestination = TProfile extends Profile ? TDestination : never export type Mapper = { register: (profiles: Profile[] | readonly Profile[] | Profile) => void, @@ -23,9 +25,9 @@ export type Mapper & string, TDestinationKey extends keyof ProfileIndex[TSourceKey] & string - > (sourceKey: TSourceKey, source: ProfileSource[TSourceKey][TDestinationKey]>, destinationKey: TDestinationKey) => ProfileDestination[TSourceKey][TDestinationKey]>, + > (sourceKey: TSourceKey, source: ProfileSource[TSourceKey][TDestinationKey], TSourceKey>, destinationKey: TDestinationKey) => ProfileDestination[TSourceKey][TDestinationKey], TSourceKey>, mapMany: < TSourceKey extends keyof ProfileIndex & string, TDestinationKey extends keyof ProfileIndex[TSourceKey] & string - > (sourceKey: TSourceKey, sourceArray: ProfileSource[TSourceKey][TDestinationKey]>[], destinationKey: TDestinationKey) => ProfileDestination[TSourceKey][TDestinationKey]>[], + > (sourceKey: TSourceKey, sourceArray: ProfileSource[TSourceKey][TDestinationKey], TSourceKey>[], destinationKey: TDestinationKey) => ProfileDestination[TSourceKey][TDestinationKey], TSourceKey>[], } diff --git a/src/types/profile.ts b/src/types/profile.ts index 1217a7e..ec29fd8 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -10,5 +10,4 @@ export type ProfileKey = `${T['sourceKey']}-${T['destinationK // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ExtractSourceKeys = TProfile extends Profile ? TSourceKey : never - export type Profiles = readonly Profile[]