Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

No way to type an object with null prototype #1108

Open
Arnavion opened this issue Nov 9, 2014 · 23 comments
Open

No way to type an object with null prototype #1108

Arnavion opened this issue Nov 9, 2014 · 23 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@Arnavion
Copy link
Contributor

Arnavion commented Nov 9, 2014

Currently the base type of all objects in TS seems to be {}, which looks like an empty type but actually has members inherited from the Object object. This leaves no way to express a type with null prototype.

var foo: ??? = Object.create(null);
foo.toString(); // Want a compile error here

I did accidentally discover that it's possible to type a variable as void:

var foo: void = Object.create(null);

This appears to suppress intellisense for members in VS and the playground, and it is assignable to any (Object.keys(foo) compiles), so it would seem to be what I'm looking for. However this still lets foo.toString() compile somehow - I imagine the void type is getting elevated to Object automatically.

Edit: Ryan's workaround to subtype Object and override all its members to void prevents those properties from being called as functions, but as he points out, that doesn't prevent them from being accessed.

var bar = foo.toString; // Want a compiler error here too. Won't get one with Ryan's workaround.
@mhegazy mhegazy added the Bug A bug in TypeScript label Nov 10, 2014
@RyanCavanaugh
Copy link
Member

Not sure why 'Bug' label as this is per spec (3.8.1 Apparent Type).

The best workaround would be to create an object called NotAnObject that has the properties of the built-in Object type, and override all its properties:

interface NotAnObject {
    toString: void;
    toLocaleString: void;
    valueOf: void;
    // etc
}

var x: NotAnObject = Object.create(null);

Technically you could then still write things like var n = x.toString, but x.toString() would be an error as desired.

@Arnavion
Copy link
Contributor Author

Okay, I've added the requirement of not wanting access to the property to compile either :)

Null-prototype objects are only useful in a minor number of cases (maps, etc.) so I suppose there's no pressing reason to fix this.

@mhegazy mhegazy added Suggestion An idea for TypeScript and removed Bug A bug in TypeScript labels Nov 10, 2014
@mhegazy
Copy link
Contributor

mhegazy commented Nov 10, 2014

Marking it as a suggestion. this is a supported ES6 pattern that will need to support

@Arnavion
Copy link
Contributor Author

Seems this has changed in 1.4. If foo is typed as void, foo.toString is now an error. Playground

$ node ./tsc.js ./foo.ts
foo.ts(1,20): error TS2339: Property 'toString' does not exist on type 'void'.

Is this deliberate? Is it going to stay like this and can I use this feature?

@Arnavion
Copy link
Contributor Author

Pointed out by @fdecampredon on IRC - by changing @RyanCavanaugh 's example to a class and making the members private, one can make accessing the property also an error.

class NullProto {
    private toString: any;
    /* other Object.prototype members */
}

var foo: NullProto = Object.create(null);
var bar = foo.toString; // error - Property 'toString' is private

Pointed out by @spion on IRC - this type still has a usable constructor property, so that needs to be eclipsed as well (based on https://gist.github.com/spion/65e1f98b168be67daefe):

class NullProto {
    private "constructor": NullProto;
    private toString: any;
    private toLocaleString: any;
    private valueOf: any;
    private hasOwnProperty: any;
    private isPrototypeOf: any;
    private propertyIsEnumerable: any;
}

@mhegazy mhegazy added the Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. label Dec 9, 2015
@blakeembrey
Copy link
Contributor

blakeembrey commented Jun 1, 2016

@mhegazy This is going to be a lot more useful and important as time goes by. Would it be possible to maybe make null prototypes the default? Any interface expected to have object prototype methods could easily do extends Object, while every other interface can work on existing structural comparisons. Otherwise, I think there needs to be a built-in type for this - it's not possible to create an interface as described in this issue as every other object can not be assigned to it.

@blakeembrey
Copy link
Contributor

blakeembrey commented Jun 1, 2016

For example, we can't even start describing the node 6 headers object. This errors as string is not assignable to void.

interface Void {
  hasOwnProperty: void
}

interface Headers extends Void {
  [header: string]: string
}

Edit: To clarify, using any does not add value here as people could then still do headers.hasOwnProperty(x) and run into runtime issues.

@Arnavion
Copy link
Contributor Author

Arnavion commented Jun 1, 2016

@blakeembrey

Would it be possible to maybe make null prototypes the default? Any interface expected to have object prototype methods could easily do extends Object

Well that would violate ES spec, but since ES does allow classes to extend null and get a null prototype, it might be nice to support it for interfaces as well.

interface Headers extends null {
  [header: string]: string
}

(Of course currently this doesn't compile, and TS doesn't treat extends null for classes correctly either.)

@blakeembrey
Copy link
Contributor

blakeembrey commented Jun 1, 2016

@Arnavion Why would that violate ES spec? I was talking about using it in a type position, not a value position.

interface X extends Object {}

I do like extends null more, which would keep TypeScript in sync with JavaScript semantics where object literals extend object by default (comparison to interfaces here) while we have a syntax that allows nulling the prototype properly.

@blakeembrey
Copy link
Contributor

blakeembrey commented Sep 26, 2016

Does this issue make more sense to prioritise now that TypeScript 2.0 has been released with null types? Being able to write extends null would be helpful to people working with these APIs, improving documentation and consistency for users.

@mhegazy
Copy link
Contributor

mhegazy commented Sep 27, 2016

The change is not simple. that is why we need a proposal on how to implement it.

@blakeembrey
Copy link
Contributor

I don't understand the internals enough to submit anything, but would be happy to give it a go and write up a proposal. Should the proposal be a new issue? Is it alright if I just model if off an existing proposal (feel free to provide a link if there's a proposal you format that works well)?

@mhegazy
Copy link
Contributor

mhegazy commented Sep 28, 2016

Should the proposal be a new issue?

Not necessarly. if you feel the proposal would cover more issues, then it is fine to have it by itself. jsut up to you.

Is it alright if I just model if off an existing proposal (feel free to provide a link if there's a proposal you format that works well)?

Sure. #10727 looks like a good one.

This also would be helpful: https://github.com/Microsoft/TypeScript/wiki/Writing-Good-Design-Proposals

We are not really looking for a formal proposal, just a formulation for what changes would be needed to implement, and what impact this has on other parts of the type system.

@alexsasharegan
Copy link

This seems to be google's canonical result for typescript object null prototype. Is there any update on this? A workaround?

@texastoland
Copy link
Contributor

texastoland commented Aug 18, 2018

We are not really looking for a formal proposal, just a formulation for what changes would be needed to implement, and what impact this has on other parts of the type system.

@mhegazy @RyanCavanaugh what would you think about just changing lib.d.ts like below?

namespace Object {
  export type NullPrototype = Record<keyof Object, never>
}

interface ObjectConstructor {
  create
    <T extends object | null>
    (proto: T):
    T extends null ? Object.NullPrototype : T
}

Playground

interface IDictLike<T> {[key: string]: T}
type DictLike = IDictLike<string> & Object.NullPrototype
const dictLike: DictLike = Object.create(null)

console.log(dictLike.key)
console.log(dictLike.toString())
//          ^^^^^^^^^^^^^^^^^^^
// Cannot invoke an expression whose type lacks a call signature.
// Type 'never' has no compatible call signatures.

// Same type as Object.create(Array.prototype)
const arrayLike = Object.create([])
console.log(arrayLike.key)
//                    ^^^
// Property 'key' does not exist on type 'any[]'. Did you mean 'keys'?
console.log(arrayLike.toString())

@JakeTunaley
Copy link

@texastoland That solution doesn't work when trying to override a property that's in Object.prototype.

type NullPrototype = Record<keyof Object, never>;

const x: Record<'toString', string> & NullPrototype = Object.create(null);
x.toString = 'hello world';
// ^^^^^^^
// Type '"hello world"' is not assignable to type 'never'. ts(2322)
x.toString.toLowerCase();
//         ^^^^^^^^^^^
// Property 'toLowerCase' does not exist on type 'never'. ts(2339)

The above is actually a general property of intersection property types with no overlap:

interface ObjectA {
	prop: string;
}

interface ObjectB {
	prop: number;
}

type Intersection = ObjectA & ObjectB;

const a: Intersection = null as any;
a.prop = 'hello world';
// ^^^
//Type '"hello world"' is not assignable to type 'never'. ts(2322)
a.prop = 0;
// ^^^
// Type '0' is not assignable to type 'never'.ts(2322)

@pygy
Copy link

pygy commented Apr 30, 2020

Since the resulting type is currently an explicit any, you can do

const x: null = Object.create(null)

and it will compile just fine, even with checks for implicit anys.

These objects are used as dictionaries, in scenarios where arbitrary content may come from users (and you don't want __proto__ to be set). Engines (well, Chrome, at least) don't try to create a hidden class for them, they are always accessed as dicts)

Forbidding the keys found on the Object prototype makes it impossible to use these objects as arbitrary dicts, defeating the purpose.

What if you made it an implicit any, that causes a type error unless properly typed?

You'd also need something along this, if it was valid, to properly express the pattern:

type Diff<T, U> = T extends U ? never : T; 

type OProtoKey = keyof typeof Object.prototype
type PartialRecord<
  T,
  K extends string | number | symbol = string | number | symbol,
  OP extends OProtoKey = OProtoKey
> = {
  [P in K]?: P extends OProtoKey ? never : T;
} & {
  [Q in OP]?: T;
}

const x:PartialRecord<string> = Object.create(null)

const o = x[4]
x.toString

playground

Edit: getting closer. Now toString has a type of (() => string) & string. My mental model of TS is full of holes, I have no idea why the P extends OProtoKey ? never : T; is ineffective in neutering the proto's field... Diff in the P in K pattern is also ineffective (I'm less surprised here).

Note that this is incomplete, as the OP type needs to be filtered manually. It is just a PoC. What's really needed is a way to neuter the prototype lookup, which looks hardwired.

Edit: Getting closer, but I still can't shadow all the keys:

type Dict<
  T,
  U extends string | number | symbol = string | number | symbol,
  K extends string | number | symbol = (string | number | symbol) & U,
  OPK extends keyof Object = keyof Object & U

> = (
  ({[P in K]?: T;}) & ({ [Q in keyof Object]?: never; } | { [R in OPK]?: T; })
)

const x:Dict<string, "toString" | "a"> = Object.create(null);
x.toString; // string | undefined
x.a;        // string | undefined


x.yy
//^^
// Property 'yy' does not exist on type '({ toString?: string | undefined; a?: string | undefined; } & { toString?: undefined; }) | ({ toString?: string | undefined; a?: string | undefined; } & { toString?: string | undefined; })'.
// Property 'yy' does not exist on type '{ toString?: string | undefined; a?: string | undefined; } & { toString?: undefined; }'.x.valueOf

// but...
x.valueOf   // (method) Object.valueOf(): Object

Edit: with

type Dict<
  T,
  U extends string | number | symbol = string | number | symbol,
  K extends string | number | symbol = (string | number | symbol) & U,
  OPK extends keyof Object = keyof Object & U

> = (
  ({[P in K]?: T;}) & (
    { [Q in keyof Object]?: Q extends U ? T: never; } |
    { [R in keyof Object]?: R extends U ? T: never; }
  )
)

x.valueOfhas now a type of never. But if I remove one of the ... in keyof Objectlines, it has a type of (() => Object) & undefined. Getting closer than ever, but still puzzled.

@snowteamer
Copy link

snowteamer commented Jun 8, 2020

Any progress on this issue ?

I for one have a lot of functions which accept and return such bare, null-prototyped objects.

I also often prefer to instantiate new empty objects with Object.create(null) rather than with {}, since I rarely need them to inherit the legacy object prototype methods.

So currently, as a workaround I use custom type definitions like BareObject, which are good enough for self-documentation, but which the compiler interprets as something slightly different, weakening the type-safety as a whole.

@KilianKilmister
Copy link

If i'm looking to create a large inheritance structure, I prefer them to start on a null-prototype, It inherently protects them from possible prototype polution on the Object.prototype and as I basically never need to support the legacy Object.prototype methods, there is no real downside.

The lack of a proper Null object type in TS has made me use it less, and i dislike that.

@lucien-perouze
Copy link

Any update on this ?

I understand that it's not the most critical feature on Typescript but for some edge cases it's very frustrating not to have the correct type. It feels like Typescript forgot one type of javascript data. Of course the cases where you need a null-prototype object properly typed are rare but not nonexistent.

For example if you are developing a third party library that uses null-prototype object to avoid any collision with names like "hasOwnProperty" or "toString". Or just for performance sake. And you want to indicate to your library's users that those feature are not accessible on those objects. But you can't so it can leads to some deep issues.

I can imagine the challenge of introducing new types or keywords to Typescript. I think the simplest way to specify that an object doesn't have a prototype is to allow a definition of the prototype the same way Object.create() works so we could set it to null if needed (or a custom prototype which ties with the concept of inheritance).

It could feel like 'passing an argument' to object types to define the prototype. The same way an interface can take arguments with dynamic typing to play with it. It could be something like that :

const test: object<null> = Object.create(null);

interface Test<null> {
  toto: string;
  anotherObject?: object<SomeInterface>
}

function Test(a: { toto: string; }<null>) {
}

// tsx
return (
  <Component<object<null>> />
)

Hope this will one day make it's way to Typescript ...

@janickvwcotiss
Copy link

Any updates on this? I am constantly running this problem. It's been 9 years 😢

@SimplyLinn
Copy link

I made this, it's not pretty, but if you're ok with it not being pretty and a bit of a bodge... https://www.npmjs.com/package/null-proto-ts

@dmchurch
Copy link

I feel like the only possible way to do this correctly is to model it the way it actually exists in JavaScript: specifically, that the "null-prototype object" is in fact the actual root of the type tree, not object itself. Anything that involves taking TypeScript's existing Object type and trying to remove the existing properties is going to run into all sorts of problems, not least of which is that objects that look like Object should be assignable to a variable of type "null-prototype object", but not the other way around. This may be a little counterintuitive, since you generally don't want to put an Object into a variable that wants a null-proto map, but it's an unavoidable consequence of TypeScript's duck-typing model:

  1. A variable intended to hold a null-prototype map must be able to hold a mapping with any string value as a key, including "toString" et al. (otherwise there is little point in using the null prototype to begin with.) For example, I'm using null-prototype objects to track changes in a set of existing objects over time, and if one of them decides to change their toString property I want to know: nullProtoMap = {__proto__: null, toString() {return "[object Object]";}}
  2. Thus, it must be possible to assign an object which is a null-prototype copy of Object.prototype to this map: nullProtoMap = Object.create(null, Object.getOwnPropertyDescriptors(Object.prototype))
  3. For any object that can be assigned to a null-prototype variable, a derived object must also be able to be assigned to it: nullProtoMap = {__proto__: Object.create(null, Object.getOwnPropertyDescriptors(Object.prototype))}
  4. TypeScript will never differentiate between two different non-exotic objects whose property descriptors all compare strictly equal, so the above is equivalent to nullProtoMap = {__proto__: Object.prototype} which is just syntactic flavor for nullProtoMap = {}

In terms of syntax, I'd suggest just going with the ES6 standard and adopting extends null as the way to denote classes and interfaces with null prototype, and {__proto__: TypeName} as the way to denote a type literal that doesn't derive from Object. This latter is a backwards-compatibility risk, given that at the moment it denotes a hypothetical valid type with the property __proto__ that takes a value of type TypeName, but I'd mitigate that in two ways:

  1. Make TypeScript's type syntax more strict than ES6's. Only interpret objects with a bare unquoted literal __proto__ property as specifying prototype, but allow {"__proto__": TypeName} and {["__proto__"]: TypeName} both to specify a type with a property named "__proto__".
  2. Accept that anyone who has written existing code with a bare unquoted __proto__ property in their type literals has been asking for trouble all along, and that really we're doing them a favor by providing it to them.

What does this need to move forward? Does it still need an official proposal created?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests