Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Restricting type to extend object in TypeScript doesn't work - string is accepted

Tags:

typescript

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.

like image 433
Ivan Yarych Avatar asked Dec 27 '25 14:12

Ivan Yarych


2 Answers

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

like image 166
jcalz Avatar answered Dec 30 '25 17:12

jcalz


Solution:

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.

like image 41
Ahmed Abdelbaset Avatar answered Dec 30 '25 16:12

Ahmed Abdelbaset



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!