29

UPDATE 2021

For a working solution using newer features see this answer https://stackoverflow.com/a/59647842/1323504


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.

21
  • 2
    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.
    – deceze
    Commented Feb 3, 2017 at 15:47
  • 3
    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.
    – deceze
    Commented Feb 3, 2017 at 15:51
  • 1
    @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.
    – Mike Cluck
    Commented Feb 3, 2017 at 16:09
  • 1
    how about something like this?typescriptlang.org/play/…
    – toskv
    Commented Feb 3, 2017 at 16:10
  • 1
    @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.
    – Mike Cluck
    Commented Feb 3, 2017 at 16:23

4 Answers 4

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.

3
  • 1
    Thank you for posting this, sorry for not noticing it so long. This is indeed a better approach using the up-to-date feature. Commented May 25, 2021 at 19:54
  • 1
    Note that symbol is not a part of JSON. Commented Sep 21, 2021 at 6:18
  • 1
    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 object Commented Sep 3, 2022 at 22:15
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'.
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. Commented Feb 3, 2017 at 16:10
  • 1
    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(). Commented Feb 3, 2017 at 16:12
  • 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. Commented Feb 3, 2017 at 16:21
  • 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? Commented Feb 3, 2017 at 16:47
  • Nevermind, I've figured it out I guess. It needs to be a Function to allow any class instance to still be an Object. Commented Feb 3, 2017 at 16:48
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

0

You can try recursive type (naive solution)

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

Not good, not terrible :)

Not the answer you're looking for? Browse other questions tagged or ask your own question.