Sanshain / types-spring

improved types for every day sounding like symphony
MIT License
26 stars 1 forks source link

Consider adding safe Record type #2

Open Dimava opened 1 year ago

Dimava commented 1 year ago
interface RecordConstructor {
    new <K extends PropertyKey = PropertyKey, T = unknown>(): Record<K, T>;
    new <K extends PropertyKey, T>(entries: [K, T][]): Record<K, T>;
    keys<O extends Record<any, any>>(o: O): keyof O[];
    values<O extends Record<any, any>>(o: O): O[keyof O];
    entries<O extends Record<any, any>>(o: O): { [K in keyof O]-?: [K, O[K]] }[keyof O];
    fromEntries<T extends new () => Record<any, any>, K extends PropertyKey, V>(this: T, o: [K, V][]): Record<K, V>;
}
export const Record: RecordConstructor = class Record<K extends string | number | symbol, T> {
        constructor(entries?: [K, T][]){ /* ... */ }
    static keys<O extends Record<any, any>>(o: O) { return Object.keys(o) }
    static values<O extends Record<any, any>>(o: O) { return Object.values(o) }
    static entries<O extends Record<any, any>>(o: O) { return Object.entries(o) }
    static fromEntries<T extends new () => Record<any, any>>(this: T, o: [any, any][]) {
        return Object.assign(new this(), Object.fromEntries(o))
    }
} as any; // or a function with null prototype to remove prototype entirely, or Object.setPrototypeOf(this, null) in constructor
Object.setPrototypeOf(Record.prototype, null);
delete Record.prototype.constructor
// maybe also export type Record<K extends string | number | symbol, T> = ...; if import would override global Record type

this makes user able to use proper records, without caring about o.constructor and o.__proto__ breaking the expected behaviour, and using Record.keys() on objects user believes to be records

Sanshain commented 1 year ago

As far as I understand, you want to match the type Record (object) of the typescript, which a priori does not have object methods (__proto__, constructor, valueOf etc) with the method of its initialization:

let a = new Record([['a', 1], ['b', 2]])    // object `a` has no `prototype` and `__proro__` in ts and runtime

instead of the usual:

let a = {a: 1, b: 1}                        // object `a` has no `prototype` and `__proro__` in ts, but has in runtime  

Did I get your point right?

Dimava commented 1 year ago

I would say the original point was being able to use typed Record.keys / Record.entries for objects you know are records (to avoid typing Object.keys / Object.values) and then it's just unwrapped from there to its logical conclusion of having a complete RecordConstructor which can make nullproto records

Sanshain commented 1 year ago

I would say the original point was being able to use typed Record.keys / Record.entries for objects you know are records (to avoid typing Object.keys / Object.values) and then it's just unwrapped from there to its logical conclusion of having a complete RecordConstructor which can make nullproto records

That's what I thought at first. But I was confused that in javascript the prototype of an object does not affect to the output of Object.keys/entries:

let tt = Object.setPrototypeOf({a: 1}, {b: 2})
console.log(Object.keys(tt))                                         // will be printed just ['a'] !
console.log(tt.a)                                                    // will be printed 1
console.log(tt.b)                                                    // will be printed 2

Thus, I did not see how the presence of __proto__ and constructor in runtime or typescript could break Object.keys behavior.

However, I see that the construction is to some extent more type-safe than the usual work with objects, it cannot protect against type covariance like this:

let a = new Record([['a', 1]])
let aa = {a: 1, b: 2, c: ''}
a = aa;

for (let k of Record.keys(a)){
    console.log(a[k].toFixed())         // runtime error!
}

Of course, this example is artificial, but possible. Such covariant behavior of types in the typescript caused the rejection of the Object.keys patch (as described in the readme), as the community perceived this as a potential vulnerability.

Dimava commented 1 year ago

I think this can be bypassed by making Record having #private which will make it not not crossasignable

Sanshain commented 1 year ago

I think this can be bypassed by making Record having #private which will make it not not crossasignable

I've tried. But it didn't impact to cross assign ability behavior