1

I wanted to write a function that sums columns from rows given as key-value arrays in TypeScript. Because I often know the TS-typed column names, I would like the function to detect typos or the usage of columns that have other than number values.

Sometimes, however, I am using dynamic column names given by generic strings instead of string-literals. In that case, the function needs to check whether the column exists and has number data at run-time.

I managed to write two versions, one correctly accepting the names of columns containing number data and rejecting others when given as literals, and one correctly accepting and run-time checking column names given dynamically as generic strings. But of course, I would love to have one to work in both cases and still highlight when I put in a string-literal that does not exists as a column name or does not have number data.

Any hints appreciated much!

Please see Playground Link for the relevant TS errors:

type KeyValue = { [key: string]: unknown };

type NumberFields<Row extends KeyValue> = {
    [Property in keyof Row as Row[Property] extends number ? Property : never]: number;
};

type NumberFieldKey<Row extends KeyValue> = keyof NumberFields<Row>;

function columnSumLiteral<Row extends KeyValue>(
    array: (Row & NumberFields<Row>)[],
    field: NumberFieldKey<Row>
) {
    return array.reduce<number>((acc, x) => {
        return acc + x[field];
    }, 0);
}

function columnSumGeneric<Row extends KeyValue>(
    array: Row[],
    field: string
) {
    return array.reduce<number>((acc, x) => {
        const value = x[field];
        if (typeof value !== 'number') return acc;
        return acc + value;
    }, 0);
}

type Row = {
    textCol: string;
    numberCol: number;
};

const rows : Row[] = [
    {
        textCol: 'five',
        numberCol: 5,
    },
    {
        textCol: 'seven',
        numberCol: 7,
    },
    {
        textCol: 'nine',
        numberCol: 9,
    },
];

const numberColGeneric = 'numberCol' as string;
const textColGeneric = 'textCol' as string;

console.log('Can I join the two variants of `columnSum()` into an overloaded function and obtain the desired behavior?')

console.log('accepted as intended:', columnSumLiteral(rows, 'numberCol'));
console.log('rejected as intended, producing "undefined behavior" which is OK:', columnSumLiteral(rows, 'textCol'));
console.log('rejected but should be accepted and run-time checked:', columnSumLiteral(rows, numberColGeneric));
console.log('rejected but should be accepted and run-time checked:', columnSumLiteral(rows, textColGeneric));

console.log('accepted as intended:', columnSumGeneric(rows, 'numberCol'));
console.log('accepted but should be rejected:', columnSumGeneric(rows, 'textCol'));
console.log('accepted as intended and runtime checked:', columnSumGeneric(rows, numberColGeneric));
console.log('accepted as intended and runtime checked:', columnSumGeneric(rows, textColGeneric));
Tilman Vogel
  • 9,337
  • 4
  • 33
  • 32
  • Does [this approach](https://tsplay.dev/w299YW) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz May 18 '23 at 14:27
  • @jcalz In terms of generating errors, it indeed behaves as desired. Nice approach to swap the check from the property name to the array. However, this causes that VS Code does not come up with a useful selection of property names for name completion. If you type `columnSumLit(rows, '`, it will show you 'numberCol' as the only valid column which I quite like. – Tilman Vogel May 19 '23 at 07:33
  • Then how about [this approach](https://tsplay.dev/N5xkow)? Does that work better for you? If so I'll write that up as an answer. Let me know. – jcalz May 19 '23 at 13:58
  • @jcalz Wow, very nice. I just did a few minor changes to satisfy some "Prettier" eslint complaints: tsplay.dev/WGdG9w – Tilman Vogel May 24 '23 at 16:39
  • I'm not going to worry about your eslint settings in my answer, if you don't mind, since such things are out of scope for the question as asked. Feel free to make such changes in your own code base, of course. – jcalz May 24 '23 at 18:15

1 Answers1

0

So for the unified columnSum(array, field) function, where array is of type T[] (for generic objectlike T) and where field is of type K (for generic stringlike K), I'm looking for the following behavior for field:

  • The compiler should accept and IntelliSense should auto-suggest keys of T for which the property type is assignable to number; we can call this type NumberFieldKeys<T> (you called it NumberFieldKey<Row>).

  • The compiler should also accept any string which is not known to be a key of T.

  • The compiler should reject any string known to be a key of T where the property type is not assignable to number.

We'll need to work on these pieces separately.


First, NumberFieldKeys<T> can be defined the way you've done it, or like this:

type NumberFieldKeys<T> = 
  keyof { [K in keyof T as T[K] extends number ? K : never]: any }

or using any of the answers at In TypeScript, how to get the keys of an object type whose values are of a given type? .


In order to autosuggest some set T of string literal types but still accept arbitrary strings, we can't use the union type T | string, since that will be eagerly collapsed to string by the compiler. Instead we'll need to use a technique described at microsoft/TypeScript#29729:

type OrAnyString<T> = T | (string & {});

The type string & {} is equivalent to string (the "empty object" type {} really just means "not null or undefined) but it's not eagerly collapsed. So T | (string & {}) accepts all strings, but the compiler will still remember T in order to auto-suggest it.

So we can constrain K to OrAnyString<NumberFieldKeys<T>> and we'll get the first two bullet points above. But it won't reject known keys of T whose property type isn't number.


TypeScript lacks negated types (as implemented by never merged in microsoft/TypeScript#29317 so there's no not in the language to directly say something like NumberFieldKeys<T> | not keyof T.

Instead we need to emulate it by replacing the usage of K with a conditional type like K extends keyof T ? NumberFieldKeys<T> : K. The compiler will infer K to be the value we enter for field, and then check it against keyof T. If it is keyof T then we accept just NumberFieldKeys<T>, and if it isn't keyof T, then we accept it. If you squint at it you could see the resemblance to the hypothetical union NumberFieldKeys<T> | not keyof T.


Okay, let's put that together:

function columnSum<T extends object, K extends OrAnyString<NumberFieldKeys<T>>>(
    array: T[],
    field: K extends keyof T ? NumberFieldKeys<T> : K
) {
    return array.reduce<number>((acc, x: any) => {
        const v = x[field];
        return acc + (typeof v === "number" ? v : 0);
    }, 0);
}

console.log('accepted:', columnSum(rows, 'numberCol'));
console.log('rejected:', columnSum(rows, 'textCol'));
console.log('accepted and run-time checked:', columnSum(rows, "randomString"));
console.log('accepted and run-time checked:', columnSum(rows, numberColGeneric));
console.log('accepted and run-time checked:', columnSum(rows, textColGeneric));

columnSum([{ a: 1, b: 2, c: "", d: "e" }], ‌‸‌‸‌‸);
// suggests "a" and "b" -------------------> 

Looks good. The compiler accepts and rejects field as desired, and autosuggests only the keys corresponding to numeric properties of the array elements.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360