type-fest icon indicating copy to clipboard operation
type-fest copied to clipboard

Proposal: `PlainObject`

Open Weakky opened this issue 4 years ago β€’ 6 comments

Motivation/Use-cases

If you want to model an object that can accept anything as value, you're tempted to use one of the following approach:

const myObject: { [props: string]: any } = { foo: 'bar' }  

or

const myObject: Record<string, any> = { foo: 'bar' }  

The problem being that both { [props: string]: any } and Record<string, any> accept functions and arrays, which I believe in 99% of cases is not what you were trying to model with your type. (Playground showcase here)

Proposal

We could construct a safer type based on the existing primitives of type-fest:

import { Primitive } from 'type-fest'

/**
 * Represents a POJO. Prevents from allowing arrays and functions
 */
export type PlainObject = {
  [x: string]: Primitive | object
}

Playground Example here

Notes

  • The fact that it's very easy to construct with the existing type-fest primitives might be a case for not adding it, open for discussion

Upvote & Fund

  • We're using Polar.sh so you can upvote and help fund this issue.
  • The funding will be given to active contributors.
  • Thank you in advance for helping prioritize & fund our backlog.
Fund with Polar

Weakky avatar May 19 '20 14:05 Weakky

Your suggestion does not prevent nested things. Also what is the issue with arrays? I don't see any issue for a POJO to have an array, is there any?

I think the JsonObject provided by type-fest is probably what you want.

papb avatar May 19 '20 19:05 papb

I don't see any issue for a POJO to have an array, is there any?

The point here is not to prevent a POJO to have an array, the point is to prevent a POJO to be an array.

I think the JsonObject provided by type-fest is probably what you want.

JsonObject is not what I want as it doesn't allow classes and functions as values.

const obj: JsonObject = {
  a: () => {} // type error
}

Put differently, the PlainObject type achieves the same as what lodash.isPlainObject does, but at the type level.

_.isPlainObject(new Foo); // => false
const obj: PlainObject = new Foo // type error
 
_.isPlainObject([1, 2, 3]); // => false
const obj: PlainObject = [1, 2, 3] // type error
 
_.isPlainObject({ 'x': 0, 'y': 0 }); // => true
const obj: PlainObject = { 'x': 0, 'y': 0 } // working
 
_.isPlainObject(Object.create(null)); // => true
const obj: PlainObject = Object.create(null) // working

Weakky avatar May 20 '20 12:05 Weakky

Oh, wow, I get it now!! The fact that { [props: string]: any } accepts () => {} itself was so unexpected to me that I didn't even parse correctly what you were saying. Sorry about that.

// This is not an error! Wow!!
const foo: { [props: string]: any } = () => {}

This is so surprising to me that I would actually think this is a bug in TypeScript. What is the explanation for this?

Out of curiosity, I tried the following:

type Primitive = string | number | boolean | null | undefined | symbol | bigint
type Foo<T> = { [x: string]: T }

const foo1: Foo<any> = () => { } // OK (????????)
const foo2: Foo<unknown> = () => { } // Error
const foo3: Foo<never> = () => { } // Error
const foo4: Foo<Primitive> = () => { } // Error
const foo5: Foo<() => void> = () => { } // Error

Is any the only type that does not give an error? Or is there another Foo<MagicalTypeHere> that also does not give an error?

papb avatar May 20 '20 16:05 papb

Oh yeah, I re-read the issue and saw how it wasn't really clear given my wording. Sorry about that too πŸ™

This is so surprising to me that I would actually think this is a bug in TypeScript. What is the explanation for this?

My theory is that { [props: string]: any } gets "transformed" into an object type, which allows both arrays and functions.

I didn't open a TS issue, but I found no question at all about this while googling so I ended up thinking it was more or less assumed correct by everyone πŸ™ˆ

Is any the only type that does not give an error? Or is there another Foo<MagicalTypeHere> that also does not give an error?

any is the only one I found too.

This was found by an actual bug in one of our open-source project. The code looked like this, see if you can spot the problem πŸ€¦β€β™‚οΈ:

function pluginHook(): { [props: string]: any } {
  const internalHook = () => {
    return {
      some: { value: true } 
    }
  }

  return internalHook
}

Weakky avatar May 22 '20 09:05 Weakky

Interesting, perhaps someone else can shed some light in this topic.

see if you can spot the problem

Forgot to call the function I guess :sweat_smile:

papb avatar May 22 '20 12:05 papb

https://github.com/sindresorhus/type-fest/blob/eb96609c1b4db7afbf394e8b52fcf95b74bce159/source/internal.d.ts#L111-L116

Perhaps this could be of assistance to you?

However, it is important to note that it’s not absolutely safe in some situations. For instance, it still can't make correct judgment in this scenario:

let s = new String()

type T1 = IsPlainObject<typeof s>
//   ^? true

There is another approach that might help you, but it has a drawback: It can't determine interface

type PlainObject = Record<string, unknown>

type T1 = [1, 2, 3] extends PlainObject ? true : false
//   ^? false

type T2 = (() => string) extends PlainObject ? true : false
//   ^? false

type T3 = String extends PlainObject ? true : false
//   ^? false

type T4 = { a: 1 } extends PlainObject ? true : false
//   ^? true

interface Obj {
    a: 1
}

type T5 = Obj extends PlainObject ? true : false
//   ^? false

As far as I know, there still seems to be no perfect way to implement isPlainObject in TypeScript currently. I have been seeking help for a long time too.

13OnTheCode avatar Jan 20 '24 22:01 13OnTheCode