Is there any way to target the plain JavaScript object type in TypeScript?
Asked Answered
S

4

29

UPDATE 2021

For a working solution using newer features see this answer https://mcmap.net/q/484640/-is-there-any-way-to-target-the-plain-javascript-object-type-in-typescript


I'm trying to write a function where I'd like to indicate that it returns some kind of plain JavaScript object. The object's signature is unknown, and not interesting for now, only the fact that it's a plain object. I mean a plain object which satisfies for example jQuery's isPlainObject function. For example

{ a: 1, b: "b" }

is a plain object, but

var obj = new MyClass();

is not a "plain" object, as its constructor is not Object. jQuery does some more precise job in $.isPlainObject, but that's out of the question's scope.

If I try to use Object type, then it will be compatible to any custom object's too, as they're inherited from Object.

Is there a way to target the "plain object" type in TypeScript?

I would like a type, which would satisfy this for example.

var obj: PlainObject = { a: 1 }; // perfect
var obj2: PlainObject = new MyClass(); // compile-error: not a plain object

Use case

I have kind of a strongly-typed stub for server-side methods, like this. These stubs are generated by one of my code generators, based on ASP.NET MVC controllers.

export class MyController {
  ...
  static GetResult(id: number): JQueryPromise<PlainObject> {
    return $.post("mycontroller/getresult", ...);
  }
  ...
}

Now when I call it in a consumer class, I can do something like this.

export class MyViewModelClass {
  ...
  LoadResult(id: number): JQueryPromise<MyControllerResult> { // note the MyControllerResult strong typing here
    return MyController.GetResult(id).then(plainResult => new MyControllerResult(plainResult));
  }
  ...
}

And now imagine that the controller method returns JQueryPromise<any> or JQueryPromise<Object>. And now also imagine that by accident I write done instead of then. Now I have a hidden error, because the viewmodel method will not return the correct promise, but I won't get a compile-error.

If I had this imaginary PlainObject type, I'd expect to get a compile error stating that PlainObject cannot be converted to MyControllerResult, or something like that.

Seep answered 3/2, 2017 at 15:39 Comment(21)
could you give us an example of what you'd like to return?Marconigraph
Thank you, added some further clarificationsFearnought
In the end this means you'll pretty much accept any value, since pretty much everything in Javascript is an object and you don't even care about any specific characteristics of it. The caller of your function may decide to implement your desired object as a class for their own purposes; the resulting object will still be perfectly compatible with your expected "plain" object, especially if you don't even really care about anything about that object. While an interesting question, I somewhat fail to see the practicality of it.Clavicytherium
@Clavicytherium Thanks for the good comment, in theory you're right. I need it for a specific use-case. I need it for a method which returns a promise of an AJAX call, and I want to indicate that the promise value is a plain object (parsed by JSON.parse for example), and not any instance of any class. So I'd use it as an output type, not an input.Fearnought
You typically type hint to enforce specific characteristics of an object; why exactly are you trying to type hint for the absence of specific characteristics? It shouldn't really matter whether the method returns a class or not, that's an implementation detail. As long as that class instance still conforms to the expected behaviour, which in this case is any, that shouldn't matter.Clavicytherium
@ZoltánTamási Do you expect the result of that AJAX call to have a consistent layout? As in, could you describe the results as a type even though there isn't a constructor?Overarch
I've updated the post with my detailed example use-caseFearnought
Presumably, MyControllerResult still expects plainResult to have certain properties, right? Why don't you create a type definition for that?Overarch
@MikeC The assignment happens dynamically at runtime (like jQuery's extend), and I don't want to kind of duplicate the layout of MyControllerResult. Actually MyControllerResult is a knockoutjs viewmodel.Fearnought
Last attempt: the type system allows for inheritance and sub classes of the hinted type are equally accepted. Since any object will be a sub class of "a plain object", this type hint, if it existed, won't be able to enforce very much.Clavicytherium
What might work in your case is requiring an object with an explicitly defined index signature. This will require the --noImplicitAny compiler option. Any class or interface that does not explicitly implement the index signature will be rejected by TypeScript, although I believe Object is one of them (thus you'll need to modify the native type lib).Tabbie
@Clavicytherium Sorry but I don't get your points. There is nothign like any in the use-case example code.Fearnought
I keep using any half as a pun, and half as a hint that that may be the only real type applicable here. :)Clavicytherium
@ZoltánTamási Here's the thing: types are used to enforce contracts. If you don't have a specific contract, then you can't expect to statically enforce the typing. However, again, since MyControllerResult requires the object to have some specific properties, you should document those then use that as your type. I fail to see how PlainObject is useful here.Overarch
how about something like this?typescriptlang.org/play/…Marconigraph
I do have a contract, the contract that it should be a plain object, meaning that it's constructor is Object. This is a pretty well defined contract in my opinion. See this jsfiddle jsfiddle.net/pyd466tbFearnought
@ZoltánTamási It's really not. You're only stating that it should be an Object but that doesn't tell you anything about the kind of data you're expecting. Please explain to me why defining your contract based on the kind of data you're going to receive (i.e., what properties you expect the object to have) is a bad idea. Right now it seems like you're just focused on applying your flawed idea rather than correcting your approach.Overarch
I misused the word "contract", sorry. It's indeed not literally a contract, but it's a kind of type restriction Generally it's of course not a bad idea in any sense to enuemrate the properties, I just want to omit that, because it's irrelevant in my code, as I have dynamic mappings from these plain results. Some of the web API results have really complex structure, what I already have defined in the strongly-typed viewmodel classes, I wouldn't like to duplicate themFearnought
Okay, thank you guys for your constructive ideas, I think the end result is that it's simply not possible, so I'll rethink it.Fearnought
@ZoltánTamási If you've already defined it for your viewmodels, why not just change those descriptions to an interface and have your viewmodels use that interface? Then there's no duplication, you just say that both the viewmodel and the AJAX data use the same interface.Overarch
@MikeC There is a code duplication, because I have to build the interface with the same property names as my viewmodel. But indeed, that would be the most robust and theoretically correct approach, you're right. I just wouldn't make use of it anywhere. I just need the fact that the return type is a plain object :)Fearnought
R
17

Tested in TypeScript 3.7.2:

For a flat plain object, you can do:

type Primitive =
  | bigint
  | boolean
  | null
  | number
  | string
  | symbol
  | undefined;

type PlainObject = Record<string, Primitive>;

class MyClass {
  //
}

const obj1: PlainObject = { a: 1 }; // Works
const obj2: PlainObject = new MyClass(); // Error

For a nested plain object:

type Primitive =
  | bigint
  | boolean
  | null
  | number
  | string
  | symbol
  | undefined;

type JSONValue = Primitive | JSONObject | JSONArray;

interface JSONObject {
  [key: string]: JSONValue;
}

interface JSONArray extends Array<JSONValue> { }

const obj3: JSONObject = { a: 1 }; // Works
const obj4: JSONObject = new MyClass(); // Error

const obj5: JSONObject = { a: { b: 1 } }; // Works
const obj6: JSONObject = { a: { b: { c: 1 } } }; // Works
const obj7: JSONObject = { a: { b: { c: { d: 1 } } } }; // Works

Code is an adaptation from https://github.com/microsoft/TypeScript/issues/3496#issuecomment-128553540

For primitives, see mdn web docs > Primitive.


An alternative solution would be to use a library sindresorhus / type-fest or use the implementation from it found here: https://github.com/sindresorhus/type-fest/blob/main/source/basic.d.ts.

Radiocommunication answered 8/1, 2020 at 14:21 Comment(3)
Thank you for posting this, sorry for not noticing it so long. This is indeed a better approach using the up-to-date feature.Fearnought
Note that symbol is not a part of JSON.Bankable
This is MISLEADING. It appears to achieve the intended but it works for a totally different reason. Typescript auto adds the index signature [key: string]: ... for type aliases (object literals) and that makes them assignable to JSONObject (has the aforementioned signature). Broken: interface X { a: number; } const x: X = { a: 1 }; const obj: JSONObject = x; DOES NOT WORK even though it IS a plain object. And also broken: class MyClass {} interface MyClass { [k: string]: JSONValue; } const obj: JSONObject = new MyClass(); WORKS even though it IS NOT a plain objectMilch
T
9

In my code I have something similiar to what you're asking:

export type PlainObject = { [name: string]: any }
export type PlainObjectOf<T> = { [name: string]: T }

And I also have a type guard for that:

export function isPlainObject(obj: any): obj is PlainObject {
    return obj && obj.constructor === Object || false;
}

Edit

Ok, I understand what you're looking for, but unfortunately that is not possible.
If i understand you correctly then this is what you're after:

type PlainObject = {
    constructor: ObjectConstructor;
    [name: string]: any
}

The problem is that in 'lib.d.ts' Object is defined like so:

interface Object {
    /** The initial value of Object.prototype.constructor is the standard built-in Object constructor. */
    constructor: Function;

    ...
}

And then this:

let o: PlainObject = { key: "value" };

Results with an error:

Type '{ key: string; }' is not assignable to type 'PlainObject'.
  Types of property 'constructor' are incompatible.
    Type 'Function' is not assignable to type 'ObjectConstructor'.
      Property 'getPrototypeOf' is missing in type 'Function'.
Tramontane answered 3/2, 2017 at 16:7 Comment(9)
The problem is that an assignment like var obj: {[index:string]: any} = new MyControllerResult() is still accepted by the compiler using TS2.1. It might be related to noImplicitAny, I don't have that option, and I can't even turn it for in the current codebase.Fearnought
Why is that a problem? Every object in js is a "PlainObject", a class instance is also a plain object. It similar to doing: class A {} then class B extends A {} and let a: A = new B().Tramontane
No, a plain object is plain in the sens that it's not an instance of a "custom" class. In your code you're checking exactly that. But a type-guard is not a type hint unfortunately.Fearnought
Yes, exactly that.. thank you for pointing out the key. Do you have any idea why it is defined so? I mean why isn't the Object.constructor defined as ObjectConstructor?Fearnought
Nevermind, I've figured it out I guess. It needs to be a Function to allow any class instance to still be an Object.Fearnought
Well, that's actually a good question. In js functions are objects, and it even states in the comment above the constructor that this is the case (updated the comment in my answer). I'm not sure why then this isn't the case in the lib.d.ts file. Maybe there are reasons for it that I'm unaware of. Maybe this calls for posting an issueTramontane
Let us continue this discussion in chat.Fearnought
Here is the related TS ticket: github.com/Microsoft/TypeScript/issues/13866Fearnought
Keep in mind that this PlainObject type will incorrectly report that arrays are plain objects. This was unexpected to me, and I opened an issue about it - github.com/Microsoft/TypeScript/issues/24083.Asphyxiant
M
4

I've found this to work in TS 4.9:

type PlainObject = Record<string, {}>;

class MyClass {}

function test(one: PlainObject) {}
test({ a: 1, b: "a", c: new MyClass() }); // OK
test(1); // Error
test("a"); // Error
test([1, 2, 3]); // Error
test(new Date()); // Error
test(new Map()); // Error
test(new Set()); // Error
test(new MyClass()); // Error

TS Playground link

The difference with the accepted answer is that it also allows the plain object to contain complex values, and not only primitives:

type ObjectOfPrimitives = Record<string, Primitive>;

function test(one: ObjectOfPrimitives) {}

test({ 
  a: 1, 
  b: "a", 
  c: new MyClass(), // Error: Type 'MyClass' is not assignable to type 'ObjectOfPrimitives'.
}); 

TS Playground link

Marceline answered 9/1, 2023 at 0:44 Comment(0)
U
0

You can try recursive type (naive solution)

type SerializableObject = { [x: string]: SerializableObject | number | string | [] };

Not good, not terrible :)

Urinary answered 24/1, 2020 at 9:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.