2

I'm looking to extend the default indexed anonymous types in Typescript and can't seem to find the right syntax to get the type of the value of an indexed object, if this is at all possible?

Here is the problem:

EDIT:

I've reworked the examples for this to better explain the problem.

// How do we pass the value for T as a dictionary through to the iterator?.
interface Object {
    where<T = { [key: string]: any}, K = keyof T>(this: T, iterator: (v: any /* this should be valueof T, not any */, key?: K) => boolean | void): T;

    // This works, but you have to specify the type when calling it, this is exactly what I'm trying to avoid.
    whereAsArray<S, T = { [key: string]: S }, K = keyof T>(this: T, iterator: (v: S /* this should be valueof T, not S*/, key?: K) => boolean | void): S[] /* this should be valueof T[], not S */;

}

// This problem is agnostic of the implementation. Included here only so that the code runs. 
(function Setup() {
    if (typeof Object.prototype.where != 'function') { Object.defineProperty(Object.prototype, 'where', { value: function (iterator: (v: any, key?: string) => any) { const keys: string[] = Object.keys(this); const result: any = {}; let i: number; for (i = 0; i < keys.length; i++) { const key = keys[i]; if (this.hasOwnProperty(key)) { const res = iterator(this[key], key); if (res === true) { result[key] = (this[key]); } } } return result; }, writable: true, configurable: true, enumerable: false }); }
    if (typeof Object.prototype.whereAsArray != 'function') { Object.defineProperty(Object.prototype, 'whereAsArray', { value: function (iterator: (v: any, key?: string) => any) { const keys: string[] = Object.keys(this); const result: any[] = []; let i: number; for (i = 0; i < keys.length; i++) { const key = keys[i]; if (this.hasOwnProperty(key)) { const res = iterator(this[key], key); if (res === true) { result.push(this[key]); } } } return result; }, writable: true, configurable: true, enumerable: false }); }
})();

(function Test() {
    // Typescript knows the type of x, it is explicitly typed here as an object keyed on string, storing only numbers; a dictionary of string=>number.
    // Typescript enforces this everywhere you use this dictionary, as shown below:
    const x: { [key: string]: number } = {};
    x["foo"] = 1;
    x["bar"] = 2;

    // The code can currently be used like this, and works.. But, if you hover the 'i' variable, you will see that the type is any, 
    // because I can't figure out how to extract the type of the "value of" T in the interface?
    const results = x.where((i, _k) => i !== 1); 
    console.log(results);

    // This works. But we have to specify <number> for TS to figure this out. Question is, is it possible to have TS figure this out from the signature?
    // Having to type the function calls should not be necessary.
    const results2 = x.whereAsArray<number>((i, _k) => i === 1);
    console.log(results2);
})();

Playground link:

Typescript Playground

Rob
  • 1,687
  • 3
  • 22
  • 34
  • 1
    I don't really understand what you're trying to achieve, nor what the problem is with the code you already have. When you say "This does not work" and "neither work", can you show examples of exactly what the problem is? As in a [mcve]? – jcalz Oct 28 '20 at 20:16
  • I've updated the question with a playground example. – Rob Oct 29 '20 at 12:04
  • 1
    Links to a web IDE demonstrating an issue are a good supplement to the question, but the relevant code should also be included in the text of the question itself (See [ask] for more info). Also, the actual specific issues you are having (e.g., "how do I get this to be `string` and not `string | number`") should be written out in the question and not only as code comments. The idea is that any question-and-answer pairing should help not just the asker but also future users who might have the same problem; to help these people we should make things as clear and self-contained as possible. – jcalz Oct 29 '20 at 13:50
  • 1
    Reading through that code, I still don't understand: `where()` is declared to return an array, but the implementation (which "doesn't matter"?) is returning a non-array object. What's going on there? As for `keys()`, why not just declare its return type as `string[]` directly instead of making it generic? Given that `Object.keys()` returns `string[]`, and [for good reason](https://stackoverflow.com/questions/55012174/why-doesnt-object-keys-return-a-keyof-type-in-typescript), and given that your examples are looking for `string[]` anyway, I'm not sure what's motivating the generic. – jcalz Oct 29 '20 at 14:06
  • Apologies for going around the houses, I think I've finally wrapped my head around the problem and the question I'm asking. I've updated the question and the example. – Rob Oct 30 '20 at 12:28
  • What exactly method ```where``` should do? Can you explain it in your own words? – Николай Гольцев Nov 01 '20 at 08:21

3 Answers3

2

I'm posting this as a separate answer because you've changed the question. The typings for this are actually pretty simple.

type ValueOf<T> = T[keyof T];

interface Object {
    where<T>(this: T, iterator: (v: ValueOf<T>, key: string) => boolean | void): T; // should become Partial<T> if we are not dealing with a dictionary

    whereAsArray<T>(this: T, iterator: (v: ValueOf<T>, key: string) => boolean | void): ValueOf<T>[];
}

We do not need or want a generic for the key because these functions are for all keys, not a specific key.

Note that when defining the iterator callback, the additional arguments should be listed as required. When you call the where method with a callback, you don't need to include all of the arguments. But if you make them optional in the callback definition, you won't be able to actually use them in the body of the callback because they might be undefined.

Typescript Playground Link

I've already explained many potentially issues with defining these methods on the Object.prototype, but I do want to bring one to your attention. Our where returns the same type T as the original object. That is not inherently a problem, but it doesn't match your implementation because arrays are returned as objects rather than arrays. Typescript however expects an array to be returned, leading to runtime errors.

const z = [1, 2, 3];
const filtered: number[] = z.where(v => v > 1);
// the type is number[] but it's actually an object
console.log(filtered);
// so calling array methods on it seems fine to TS, but is a runtime error
console.log(filtered.map(n => n - 1));
Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
1

You can inherit the type on the first type parameter:

Typescript playground link

interface Object {
    whereAsArray <T, K = keyof T, V = T[keyof T] > (this: T, iterator: (v: V, key?: K) => boolean | void): T[];
}

(function Setup() {
    if (typeof Object.prototype.whereAsArray != 'function') {
        Object.defineProperty(Object.prototype, 'whereAsArray', {
            value: function(iterator: (v: any, key ? : string) => any) {
                const keys: string[] = Object.keys(this);
                const result: any[] = [];
                let i: number;
                for (i = 0; i < keys.length; i++) {
                    const key = keys[i];
                    if (this.hasOwnProperty(key)) {
                        const res = iterator(this[key], key);
                        if (res === true) {
                            result.push(this[key]);
                        }
                    }
                }
                return result;
            },
            writable: true,
            configurable: true,
            enumerable: false
        });
    }
})();

(function Test() {
    const x: {
        [key: string]: number
    } = {};
    x["foo"] = 1;
    x["bar"] = 2;

    const results = x.whereAsArray((i, _k) => i === 1);
    console.log(results);
})();
pytness
  • 357
  • 2
  • 13
-1

Ok so you have an object which contains a dictionary. That object has a method dictionaryKeys() which returns an array of keys of the dictionary and a method dictionaryWhere() which filters based on the keys and values and I guess returns a subset of the dictionary?

You can make use of the typescript Record utility type, but the important thing that you are missing is that the generic T should apply to the object, not to the individual methods.

interface DictionaryObject<T> {
    dictionaryKeys(): string[];
    dictionaryWhere(iterator: (v: T, key: string) => boolean): Record<string, T>;
}

declare const x: DictionaryObject<string>

let k: string[] = x.dictionaryKeys();         
let v: Record<string, string> = x.dictionaryWhere((i, k) => k === "foo"); 

Playground Link

Implementation:

class MyDictionary<T> {
    private _values: Record<string, T> = {};

    constructor(object: Record<string, T> = {}) {
        this._values = object;
    }

    dictionaryKeys(): string[] {
        return Object.keys(this);
    }

    dictionaryWhere(iterator: (v: T, key: string) => boolean): Record<string, T> {
        return Object.fromEntries(
            Object.entries(this._values).filter(
                ([key, value]) => iterator(value, key)
            )
        )
    }
}

note: tsconfig requires at least es2017 to use Object.fromEntries.

Edit:

I misunderstood the question and did not realize that you were trying to extend the built-in Object.prototype. This is a bad idea as it will have unintended consequences. In my opinion you should create functions which take an object rather than extending the prototype.

Not all objects are string-keyed dictionaries. Arrays are a type of object. This is why you are getting the type (string | number)[] for the keys. As explained in the docs,

keyof and T[K] interact with index signatures. An index signature parameter type must be ‘string’ or ‘number’. If you have a type with a string index signature, keyof T will be string | number (and not just string, since in JavaScript you can access an object property either by using strings (object["42"]) or numbers (object[42])).

If you're already having to pass in an explicit generic with let k = x.dictionaryKeys<string>();, I don't really see why this is any better than let k = Object.keys(x) as string[];.

You could use a function that types the array of keys based on the object, which you need to use carefully for the reasons in the thread linked by @jcalz.

const typedKeys = <T extends Record<string, any> & Record<number, never>>(obj: T): (keyof T)[] => {
    return Object.keys(obj);
}

Link

If we don't include that Record<number, never>, we actually can still pass in numeric keyed objects (due to the way things are implemented behind the scenes), and we will get bad results.

const typedKeys = <T extends Record<string, any>>(obj: T): (keyof T)[] => {
    return Object.keys(obj);
}

const a = {
    1: "",
    2: "",
};

const b = {
    one: "",
    two: "",
};

const aKeys = typedKeys(a); // we might expect a problem passing in an object with numeric keys, but there is no error
console.log( aKeys );  // BEWARE: typescript says the type is `(1|2)[]`, but the values are `["1", "2"]`

const bKeys = typedKeys(b);
console.log( bKeys ); // this one is fine

Link

You could use a function that allows you to explicitly declare the key type. Here the generic describes the keys rather than the object, so I am calling it K for clarity. We use as K[] to narrow the type to a subset of string. Since Object.keys returns string, we cannot return anything that is not assignable to string[].

const typedKeys = <K extends string>(obj: Record<K, any> & Record<number | symbol, never>): K[] => {
    return Object.keys(obj) as K[];
}

const b = {
    one: "",
    two: "",
};
 
const bKeys = typedKeys(b); // type is ("one" | "two")[], with no generic, the type is infered to these specific keys

const stringKeys = typedKeys<string>(b); // type is string[]

const badKeys = typedKeys<number>(b); // error type 'number' does not satisfy the constraint 'string'

Link

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • Thanks for the great example, although this does work it requires significant code refactoring and has implications for JSON conversions, when typescript already has a way to define dictionaries built into the language. I've added a playground example to the original question, for clarity. – Rob Oct 29 '20 at 12:15
  • Maybe this update that I've made will help better explain the problem, apologies for not understanding the problem myself until now. ;) – Rob Oct 30 '20 at 13:04