I was able to find a solution after some trial and error. Here is the complete code:
type IndexableKeyTypes = string | number | symbol
type Indexable<T = unknown> = Record<string | number, T>
type JustIndexableTypes<T> = T extends IndexableKeyTypes ? T : never
type KeysMatching<Rec, Keys> = NonNullable<
{
[RecKey in keyof Rec]: Rec[RecKey] extends Keys ? RecKey : never
}[keyof Rec]
>
type GroupBy<T extends Indexable, K extends IndexableKeys<T>> = {
[KV in JustIndexableTypes<T[K]>]: Array<T extends Record<K, KV> ? T : never>
}
type IndexableKeys<Rec> = KeysMatching<Rec, IndexableKeyTypes>
export function groupByProp<Obj extends Indexable, KeyName extends IndexableKeys<Obj>>(
xs: Obj[],
keyName: KeyName
): GroupBy<Obj, KeyName> {
type KeyValue = JustIndexableTypes<Obj[KeyName]>
const seed = {} as GroupBy<Obj, KeyName>
return xs.reduce((groupings, x) => {
const groupName = x[keyName] as KeyValue
if (groupings[groupName] === undefined) {
groupings[groupName] = []
}
groupings[groupName].push(
x as Obj extends Record<KeyName, KeyValue> ? Obj : never
)
return groupings
}, seed)
}
//
// TEST
//
import * as Semver from 'semver'
type ParsedTag =
| { type: 'unknown'; value: string }
| { type: 'release'; value: Semver.SemVer }
const tags: ParsedTag[] = [
{ type: 'release', value: Semver.parse('1.2.3')! },
{ type: 'unknown', value: 'foobar' },
]
const tagGroups = groupByProp(tags, 'type')
// Goal: .format type checks
console.log(tagGroups.release.map(tag => tag.value.format()))
You can access a TS Playground instance of this code here.
The part that initially posed a challenge for me was:
type GroupBy<T extends Indexable, K extends IndexableKeys<T>> = {
[KV in JustIndexableTypes<T[K]>]: Array<T extends Record<K, KV> ? T : never>
}
I eventually realized that by iterating over the keyed-value types, I could apply a filter expression on T
.
This solution hinges on having a discriminate property within the union, which I found to be quite reasonable and intuitive.