Home > database >  How to restrict a generic type of a method to an object in typescript?
How to restrict a generic type of a method to an object in typescript?

Time:10-05

I have been trying to come up with a generic type to my method and restrict that type to an object with following requirements.

Below is the function I'm working with. However this function is used within a custom hook in React app. Thus I want to

function useCustomHook<T>(initialData: T[]) {
  const [changes, setChanges] = React.useState<T[]>([])

  // calling the method inside the hook with an element should be retrieved from changes
  doSomething() 
}

function doSomething<T>(obj: T) {
  Object.entries(obj).forEach(([key, value]) => {
    console.log(key, value)
  })
}

// Type
type ExampleType = {
  property1: number,
  property2: string
}
// Interface
interface ExampleInterface {
  property1: number;
  property2: string;
}

It should throw an error for the following examples (basically it should not accept any primitive type such as string, number, boolean, null, undefined, ...):

doSomething('test')
doSomething(12)
doSomething(undefined)

And it should accept the following examples:

doSomething({})
doSomething({ property1: 12, property2: 'string'})


doSomething<ExampleType>({ property1: 12, property2: 'string'})
doSomething<ExampleInterface>({property1: 12, property2: 'string'})

I have already tried to change the method like so:
function doSomething<T extends Record<string, unknown>>

It works for most the examples (meaning it throws an error) but it does not work when an interface is used (throws Index signature is missing). Changing existing interfaces to types is not a solution and I think if the example function was a part of library I as the user of the library would like to have both the options - interface and type.

However it works if I change unknown to any but I believe using any should be avoided in Typescript.

I would appreciate any advice. I believe there's got to be a way to achieve it. Here is the sandbox: https://codesandbox.io/s/sweet-wildflower-hqr1t

CodePudding user response:

The right thing to do here is to constrain the type parameter T to the object type, which specifically means "a type which is not a primitive":

function doSomething<T extends object>(obj: T) {
  Object.entries(obj).forEach(([key, value]) => {
    console.log(key, value)
  })
}

And you can verify that it works that way:

doSomething('test'); // error
doSomething(12); // error
doSomething(undefined); // error
doSomething({}) // okay
doSomething({ property1: 12, property2: 'string'}) // okay
doSomething<ExampleType>({ property1: 12, property2: 'string'}) // okay
doSomething<ExampleInterface>({property1: 12, property2: 'string'}) // okay

This is exactly what the object type was meant to do in TypeScript, and why it exists.


Now, at this point I expect the response that when you use ESLint's ban-types rule with the default configuration, it complains about object with a warning of the form:

Avoid the object type, as it is currently hard to use due to not being able to assert that keys exist. See microsoft/TypeScript#21732.

This rule might be well-meaning and useful in situations, and object does have some drawbacks, but as you've seen, Record<string, unknown> is not always an improvement. That something is "hard to use" presumably doesn't mean that it should be completely banned in favor of something else, especially if that something else doesn't work for the use case. It's hard to use a knife to open a can, and you should probably use a can opener instead, but that doesn't mean you should try to slice your bread with a can opener. Different types have different use cases. And T extends object in the above code seems to be 100% the right tool for this job.

I might be a little over-emotional about this issue since I was the person who filed microsoft/TypeScript#21732, and I would love to see key in obj act as a type guard on obj that asserts property existence. But that issue absolutely does not mean that object is useless, and it's a bit exhausting to see all those GitHub issues in other projects linking to this as the reason why they carefully excised object from their code.

Oh well!

Playground link to code

CodePudding user response:

I offer this answer with the caveat that, sadly, I'm not 100% sure why this works.

But if you leave the generic unconstrained and make the argument type:

T & Record<string, unknown>

Then it works as you expect.

function doSomething<T>(obj: T & Record<string, unknown>) {
  Object.entries(obj).forEach(([key, value]) => {
    console.log(key, value)
  })
}

Playground


Hypothesis:

When you infer a generic parameter it must full satisfy that constraint. In this case Record implies an index signature which doesn't exist in the interface. T must be assignable to Record<string, unknown>. But it is not.

However, when you type the argument as T & Record<string, unknown> you tell Typescript that T can be anything but the argument is only allowed if whatever T is has string keys. If it does not, the type resovles to never and nothing is assignable to never.

At least I think. But to be honest I'm not completely solid on all semantics of type vs interface here.


Or another approach would be to explicitly blacklist all the primitive values with something like:

type NotPrimitive<T> =
   T extends string | boolean | number | null | undefined | unknown[]
    ? never
    : T

function doSomething<T>(obj: NotPrimitive<T>) {
  Object.entries(obj).forEach(([key, value]) => {
    console.log(key, value)
  })
}

Playground

  • Related