I am trying to enforce typing for an attributes object that's contained in a wrapper class. The reason behind this is to have proper typing when setting or getting individual attributes (getOne/setOne methods in the code below).
However, it doesn't seem to work properly - string is happily accepted as a parameter for AttributeCollection constructor which is wrong for the class's intended purpose:
type AttributeMap<M extends object> = {
[key in keyof M]: M[key];
};
export class AttributeCollection<M extends object> {
constructor(private attributes: AttributeMap<M>) {}
public getOne<K extends keyof AttributeMap<M>>(key: K): M[K] {
return this.attributes[key];
}
public setOne<K extends keyof AttributeMap<M>>(key: K, value: M[K]): void {
this.attributes[key] = value;
}
}
const acCorrectImplicit = new AttributeCollection({ a: 'str'}); // ok
const acCorrectExplicit = new AttributeCollection<{ a: string }>({ a: 'str'}); // ok
const acWrong = new AttributeCollection('str'); // ok, but shouldn't be
/*
The typing we get:
const acWrong: AttributeCollection<{
toString: () => string;
charAt: (pos: number) => string;
charCodeAt: (index: number) => number;
concat: (...strings: string[]) => string;
indexOf: (searchString: string, position?: number | undefined) => number;
... 37 more ...;
[Symbol.iterator]: () => IterableIterator<...>;
}>
*/
const am: AttributeMap<'str'> = 'str'; // error
console.log(acCorrectImplicit, acCorrectExplicit, acWrong, am);
I am probably passing types incorrectly somewhere - can you help me figure out what's wrong?
Here is the playground.
It looks like the mapped type AttributeMap<M> causes the inferred type argument to widen from string (a primitive) to the String interface (the wrapper object type) and it isn't rejected. This may or may not be a bug in TypeScript; I couldn't easily find an existing issue.
If you really care about ensuring that the output of AttributeMap is also compatible with the object type, you can intersect with object:
type AttributeMap<M extends object> = object & {
[K in keyof M]: M[K];
};
Then everything works as desired:
const acCorrectImplicit = new AttributeCollection({ a: 'str' }); // ok
const acCorrectExplicit = new AttributeCollection<{ a: string }>({ a: 'str' }); // ok
const acWrong = new AttributeCollection("str"); // error
Of course, for the example as given, there is no obvious reason to use AttributeMap at all. It's essentially the identity function for types. So AttributeMap<M> is more or less equivalent to M. And if you just directly use M, then your problem also goes away:
export class AttributeCollection<M extends object> {
constructor(private attributes: M) { }
public getAttributes(): M {
return this.attributes;
}
}
const acCorrectImplicit = new AttributeCollection({ a: 'str' }); // ok
const acCorrectExplicit = new AttributeCollection<{ a: string }>({ a: 'str' }); // ok
const acWrong = new AttributeCollection("str"); // error
Playground link to code
Use NoInfer<M> to forbid TS from infering the primitive methods as object:
export class AttributeCollection<M extends object> {
constructor(private attributes: AttributeMap<NoInfer<M>>) {}
public getAttributes(): AttributeMap<NoInfer<M>> {
return this.attributes;
}
}
Note: This requires typescript@^5.4.
const acWrong = new AttributeCollection("string"); // error as expected
Playground
It seems like a bug in TypeScript that infers primitive methods as object rather than the actual type.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With