collections.group-by-key@1.0.0
impl/typescript.ts
2,769 bytes · the TypeScript implementation · view raw
/** An open record, the manifest's `record`: a JSON-ish map whose shape is not known ahead of time. */
type GroupableRecord = Readonly<Record<string, unknown>>;
/** Largest integer JavaScript can hold exactly; beyond it the three languages disagree. */
const SAFE_INTEGER = 9007199254740991;
/**
* The name of the group a value belongs to.
*
* Each language has its own default string conversion and they disagree
* (Python prints True where JavaScript prints true, and 1.0 where JavaScript
* prints 1), so the rendering is spelled out here instead of inherited.
*/
function groupNameOf(value: unknown, key: string): string {
// Absent and null are the same thing: a document that omits a field and one
// that nulls it mean the same to every reader.
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
if (!Number.isInteger(value)) {
throw new TypeError(`cannot group by the fractional number ${value} at "${key}"`);
}
if (Math.abs(value) > SAFE_INTEGER) {
throw new RangeError(`cannot group by the out-of-range number ${value} at "${key}"`);
}
return String(value);
}
throw new TypeError(`cannot group by the list or map at "${key}"`);
}
/**
* Group `records` by the value at `key`.
*
* Records keep their input order inside each group, so grouping a sorted list
* never unsorts it - the property callers actually depend on.
*/
export function groupByKey(
records: readonly GroupableRecord[],
key: string,
): Readonly<Record<string, readonly GroupableRecord[]>> {
if (!Array.isArray(records)) {
throw new TypeError("groupByKey needs a list of records");
}
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("groupByKey needs a non-empty key name");
}
// A null prototype, so that a record grouping under "__proto__" or
// "constructor" creates an ordinary group instead of corrupting the result.
const groups: { [group: string]: GroupableRecord[] } = Object.create(null);
for (const record of records) {
const name = groupNameOf(record === null || record === undefined ? undefined : record[key], key);
if (groups[name] === undefined) groups[name] = [];
groups[name].push(record);
}
return groups;
}
/** How many records fall in each group, without carrying the records themselves. */
export function countByKey(records: readonly GroupableRecord[], key: string): Readonly<Record<string, number>> {
const counts: { [group: string]: number } = Object.create(null);
for (const [name, group] of Object.entries(groupByKey(records, key))) {
counts[name] = group.length;
}
return counts;
}