lua-language-server icon indicating copy to clipboard operation
lua-language-server copied to clipboard

[Feature Request] Add a strong typed struct/class that cannot assign a new field that is not defined in the struct/class.

Open eminor1988 opened this issue 2 years ago • 66 comments

  • Example:
---@struct Student
---@field study_level integer

---@type Student
local new_student = {
    study_level = 0,
    tobacco_level = 1, -- This should be error.
}
  • Usage: We can do refactoring easier when rename the fields with this feature.

eminor1988 avatar Mar 08 '23 04:03 eminor1988

Similar previous discussions:

  • https://github.com/LuaLS/lua-language-server/discussions/1602
  • https://github.com/LuaLS/lua-language-server/discussions/1652
  • https://github.com/LuaLS/lua-language-server/issues/1823

firas-assaad avatar Mar 09 '23 10:03 firas-assaad

I'd like to see this too. I'm in favour of struct because it's similar to C#, but exact works fine too. I'm also liking other suggestions for built-in types like Exact<MyClass> instead of more @ styled notation because that might enable support for future features with a more modular approach.

For example, ReadOnly<T> could then also be added to change the behaviour of the wrapped generic type T where T is a collection data type (e.g., a table or list) to tell LuaLS that its read-only, or Callable<MyTable> might be good for declaring that a table has the __call meta-method so you can call it like a function. Who knows 🤷 but it sounds interesting.

Mayron avatar Apr 07 '23 13:04 Mayron

Would you accept PRs regarding this or is this subject completely off the table?

Calandiel avatar Apr 24 '23 11:04 Calandiel

Would you accept PRs regarding this or is this subject completely off the table?

I'm glad to accept PRs.

sumneko avatar Apr 24 '23 11:04 sumneko

Really need this feature (for one, it catches typos). It shouldn't be too hard to implement?

Referencing an undefined variable emits an LSP error or warning (even regardless of the fact that it is not a runtime error to do so) and this is a good thing, so why wouldn't the same hold true for table indices?

IIRC, typescript disallows extra keys by default (or maybe it's opt-in behavior to have it that way, I don't remember), and then you can allow additional arbitrary keys on a type by it defining/declaring like this:

type MyObj = {
    [key: string]: any;
    definedKey1: boolean;
    definedKey2: string;
    ...

instead of like this:

type MyObj = {
    definedKey1: boolean;
    definedKey2: string;
    ...

I don't think this is totally necessary however. A special symbol, type constructor, or additional annotation (kinda like @field is its own annotation and goes on its own line) would suffice (whether it's Freeze<> or Exact<> or ---@exact or whatever else).

tmillr avatar May 18 '23 10:05 tmillr

An exact table for the input of functions is also something I would also be very interested in! Initially I was just using params everywhere for function input definitions, but wanted to switch over to using exact dicts as the input to functions utilizing @class, as I thought it made the code cleaner (especially in places where the arguments in should be 'nested').

One thing I would like to see though if this every makes it in, is the possibility to mark fields on classes as optional using the normal ? syntax. When using the @class I want to require some fields, allow but not require some other fields, and disallow everything else

GiuseppeIII avatar May 29 '23 17:05 GiuseppeIII

My current idea is

---@class Student
---@exact
---@field study_level integer

or

---@class Student <exact>
---@field study_level integer

Any other good ideas are also welcome.

(Translated by ChatGPT)

sumneko avatar May 31 '23 09:05 sumneko

I know it'll break existing code and require work to update the definitions everywhere, but would you consider simply changing the existing @class so that it enforces exactness by default? If a user wants to opt out for certain fields, they'll have to manually annotate them as having nil values:

---@field study_level? integer
-- or this, but I prefer the first syntax:
---@field study_level integer?

This is how TypeScript works and while Lua annotations and TypeScript are different, I think enforcing types to be exact by default is a more consistent design that forces the user to think about class fields and whether they should be required or optional.

Of course, I understand the amount of headache involved in updating large codebases and addon libraries, but I thought it's worth mentioning this option if you want to consider it for a future major version.

firas-assaad avatar May 31 '23 09:05 firas-assaad

I agree with @firas-assaad . As a compromise this could be enabled by default when only strict is enabled

lewis6991 avatar May 31 '23 09:05 lewis6991

I know it'll break existing code and require work to update the definitions everywhere, but would you consider simply changing the existing so that it enforces exactness by default? If a user wants to opt out for certain fields, they'll have to manually annotate them as having nil values:@class

---@field study_level? integer
-- or this, but I prefer the first syntax:
---@field study_level integer?

This is how TypeScript works and while Lua annotations and TypeScript are different, I think enforcing types to be exact by default is a more consistent design that forces the user to think about class fields and whether they should be required or optional.

Of course, I understand the amount of headache involved in updating large codebases and addon libraries, but I thought it's worth mentioning this option if you want to consider it for a future major version.

No, because many users will declare fields using a mixed approach, for example:

---@class MyClass
---@field id integer
local M = Class 'MyClass'

M.showName = 'MyClass'

function M:init()
    self.list = {}
    self.id  = nextID()
end

(Translated by ChatGPT)

sumneko avatar May 31 '23 09:05 sumneko

No, because many users will declare fields using a mixed approach, for example:

---@class MyClass
---@field id integer
local M = Class 'MyClass'

M.showName = 'MyClass'

function M:init()
    self.list = {}
    self.id  = nextID()
end

(Translated by ChatGPT)

In that case the explicitly set fields are not optional unless they're nil-able, or do you meant that it's technically complex to implement?

Anyway it's just an idea if you want to consider a stricter and more consistent type system instead of adding more special keywords. One thing I notice with the language server is that because users (myself included) ask for a lot of orthogonal features, and there is usually one implementer, the annotations start to get more complex and more inconsistencies can arise.

To be clear, I think you're doing a great job managing all this complexity and carefully reviewing suggestions, but I bring up TypeScript because it's a mature language that went through some similar discussions, and the result is a more consistent and strict model that catches many user errors.

I know that I'm probably not thinking of a lot of the other use cases where my idea won't work. Having any kind of @exact annotation would still be extremely helpful. (I prefer ---@class Student <exact> in your example, assuming there will be new kinds of 'attributes' added to classes in the future)

firas-assaad avatar May 31 '23 10:05 firas-assaad

I can try adding an option that is enabled by default to enable this feature. If many users give feedback that they don't like this feature, I can downgrade it to default disable and provide new annotations to manually mark it.

(Translated by ChatGPT)

sumneko avatar May 31 '23 10:05 sumneko

I can try adding an option that is enabled by default to enable this feature. If many users give feedback that they don't like this feature, I can downgrade it to default disable and provide new annotations to manually mark it.

(Translated by ChatGPT)

That's a good compromise. I assume it'll still be in a new major version (4?) because I know this will break most of my existing code 😅

You can also make a separate pinned issue to announce this change and solicit more user feedback before doing it, like you did for some features in the past. That way more users will see it coming, at least.

firas-assaad avatar May 31 '23 10:05 firas-assaad

It's also worth mentioning that TypeScript provides a Partial<className> syntax for cases where you want to opt-out of exact types and tell the compiler that all the fields are optional:

interface Person  {
    name:string;
    address:string
    age:number
}
 
let person:Partial<Person>= {}     //No error. All Properties are now optional in person object

firas-assaad avatar May 31 '23 10:05 firas-assaad

I can try adding an option that is enabled by default to enable this feature. If many users give feedback that they don't like this feature, I can downgrade it to default disable and provide new annotations to manually mark it.

(Translated by ChatGPT)

I like that idea. I think this feature is a big (good) one, even if it comes to be an opt-in feature.

tmillr avatar May 31 '23 11:05 tmillr

@sumneko could it be released as a "preview" version on VS Code? This would allow users to opt in to this version, try it out, update their definitions, and provide feedback on it.

I do agree with the idea of strict by default, as TypeScript does it and it can help prevent a lot of errors – however, it can also add more work, which some may not want:

const myMap = new Map<string, number>();

// Property 'children' does not exist on type 'Map<string, number>'
myMap.children = [];

Need to add .children field somehow

interface ParentMap<K, V> extends Map<K, V> {
  children: Map<K, V>[];
}

const myMap = new Map<string, number>() as ParentMap<string, number>;

myMap.children = [];

carsakiller avatar May 31 '23 13:05 carsakiller

No, because many users will declare fields using a mixed approach, for example:

Thinking back about the example and what @carsakiller wrote, my suggestion to assume explicitly set fields (ones defined directly outside of the class annotation) are not optional would weaken the type system by not allowing the Language server to warn about unintentional typos. Preventing the definition of new fields was one of the reasons I started a discussion about such a feature https://github.com/LuaLS/lua-language-server/discussions/1602

I'm not sure what's the best approach in this case. Making classes strict by default will enable warnings if you forget to set a property, but allowing a mixed approach to define fields won't catch other kinds of errors. The mixed approach is particularly needed for defining class methods and self variables.

firas-assaad avatar May 31 '23 15:05 firas-assaad

No, because many users will declare fields using a mixed approach, for example:

Thinking back about the example and what @carsakiller wrote, my suggestion to assume explicitly set fields (ones defined directly outside of the class annotation) are not optional would weaken the type system by not allowing the Language server to warn about unintentional typos. Preventing the definition of new fields was one of the reasons I started a discussion about such a feature #1602

I'm not sure what's the best approach in this case. Making classes strict by default will enable warnings if you forget to set a property, but allowing a mixed approach to define fields won't catch other kinds of errors. The mixed approach is particularly needed for defining class methods and self variables.

Just to clarify, it would warn about typos in cases where you are using the class, just not in the case where you are defining the class (which for me is a nice trade-off, but I am sure there are others with differing opinions on this).

GiuseppeIII avatar May 31 '23 18:05 GiuseppeIII

It might be able to warn about the use case in this issue (defining a new variable of a type) but it creates a new distinction between the following forms:

---@class Class
---@field y integer
local cls = { y = 1 }
cls.x = true -- no issues, x is now a required part of the `Class` class

---@type Class
local instance = { y = 1, x = false } -- warning if you forget y or x
instance.z = 'hi' -- warning?

That could be a valid compromise if that's technically possible, but it's a new concept for users to learn.

Another question is about self variables. Assuming we're using some OOP library like middleclass.

---@class Class
local Cls= class('Class')

function Cls:initialize(value)
    self.x =value
end

function Cls:func()
   self.y = 1
end

---@type Class
local instance = Cls() -- what does the language server think about `x` and `y`?

There are probably more examples I'm not thinking of. In this example it might be fine to assume that exact type checking is only done if you use the {} table initialization to set variables, so that it would still work with construction function calls.

A different idea is to treat all newly defined variables on the class as optional. That way all the self variables and the class functions are ignored when checking the required fields on an instance. If a user wants to explicitly make a self variable required they'll have to add an explicit @field annotation under the @class one.

With this approach and going back to my first example with cls.x = true on the class, x type should be viewed a non-required boolean. It's not assumed to be nil within the class definition (exactly as it works right now), but users defining new instances of the class don't have to explicitly specify it either.

That means you probably want a new distinction in the language server between being required and being nil-able. One idea is to differentiate the following two forms: ---@field required_var integer? and ---@field optional_var? integer?. Also see this topic about required nullable arguments https://github.com/LuaLS/lua-language-server/issues/2070#issuecomment-1510456403

Finally, if we do end up making fields required by default, then in addition to adding a configuration to disable this behavior, I'd also suggest adding a mechanism for users to tell the language server that they know what they're doing and want to explicitly add new properties to a type. (same way we do with @cast) That would provide a workaround for @carsakiller's example. Alternatively, users can disable that specific diagnostic for a single line.

firas-assaad avatar Jun 14 '23 14:06 firas-assaad

I can do this first:

---@class Class
local m = {}

m.xx = 1 -- OK

---@type Class
local m

m.xx = 1 -- OK
m.yy = 1 -- Warning

I'm not checking the assignments inside class declarations temporarily because I still haven't thought of a good way to differentiate this case:

---@class Class
local m = {}

function m:init()
    m.xx = true -- should OK
end

function m:doSomething()
    m.xxx = false -- missspell, should warning
end

sumneko avatar Aug 11 '23 11:08 sumneko

How about something like a @sealed annotation? Then this can be opt-in for specific classes.

---@class Class
local m = {}

m.xx = 1 -- OK

---@type Class
local m

m.xx = 1 -- OK
m.yy = 1 -- OK
---@class Class
---@sealed
---@field xx integer
local m = {}

m.xx = 1 -- OK

---@type Class
local m

m.xx = 1 -- OK
m.yy = 1 -- Warning

@sealed meaning the class cannot have new members dynamically added to it.

The downside of this would mean all methods would need to be declared up front.

lewis6991 avatar Aug 11 '23 11:08 lewis6991

The downside of this would mean all methods would need to be declared up front.

I cannot allow injecting fields from type references; if I did, the cost of correctly collecting these fields would be unacceptable. Therefore, you must use ---@class to inject fields.

sumneko avatar Aug 11 '23 11:08 sumneko

Ah sorry, I should have changed the example so m isn't aliased.

What I meant was this.

---@class Class
local m = {}

m.xx = 1 -- OK
m.yy = 1 -- OK
---@class Class
---@sealed
---@field xx integer
local m = {}

m.xx = 1 -- OK
m.yy = 1 -- Warning

However, I think I now understand your suggestion better. Are you saying any fields defined on the initial @class variable become fields:

---@class Class
local m = {} -- Anything assigned to `m` is added as a field of `Class`

m.xx = 1 -- OK: adds `xx` as a field to `Class`

---@type Class
local m2 -- `m2` is an instance of `Class` with the fields: `xx`.

m2.xx = 1 -- OK
m2.yy = 1 -- Warning

I think this is a good idea since it doesn't require any additional annotation syntax.

lewis6991 avatar Aug 11 '23 11:08 lewis6991

For the other case how about:

---@class Class
local m = {}

function m:init()
    m.xx = true -- OK: adding field `xx` to `m`
    self.xx = true -- OK: `xx` is a field of `m`

    self.yy = true -- OK: `yy` is a field of `m`
    m.yy = true -- OK: adding field `yy` to `m`
end

function m:doSomething()
    self.xxx = false -- WARNING: m does not have field `xxx`
end

Basically we need to check all fields that can ever be assigned m even if done dynamically.

lewis6991 avatar Aug 11 '23 11:08 lewis6991

For the other case how about:

---@class Class
local m = {}

function m:init()
    m.xx = true -- OK: adding field `xx` to `m`
    self.xx = true -- OK: `xx` is a field of `m`

    self.yy = true -- OK: `yy` is a field of `m`
    m.yy = true -- OK: adding field `yy` to `m`
end

function m:doSomething()
    self.xxx = false -- WARNING: m does not have field `xxx`
end

Basically we need to check all fields that can ever be assigned m even if done dynamically.

This does not conform to the writing habits of most people

sumneko avatar Aug 11 '23 11:08 sumneko

@carsakiller any suggests for name and syntax of exact or sealed ?

sumneko avatar Aug 11 '23 11:08 sumneko

Can I ask an example(s) of typical writing habits you wish to support?

My suggestion works up your suggestion by defining a base table that acts as a definition of the class.

lewis6991 avatar Aug 11 '23 11:08 lewis6991

Can I ask an example(s) of typical writing habits you wish to support?

My suggestion works up your suggestion by defining a base table that acts as a definition of the class.

---@class Class
---@field a number -- priority 1
local m = {
    b = 2, -- priority 2
}

m.c = 3 -- priority 3

function m:init()
    self.d = 4 -- priority 4
end

sumneko avatar Aug 11 '23 12:08 sumneko

@carsakiller any suggests for name and syntax of exact or sealed ?

I think exact is better because sealed in C# means you cannot inherit from it. I'd prefer either exact or strict personally.

Mayron avatar Aug 11 '23 12:08 Mayron

Would it be unreasonable for d to be required to be a field with @field for @exact classes? If not, the benefits of this feature are reduced since it won't catch bugs of incorrectly assigned fields in methods.

Otherwise, @firas-assaad suggestion of making d become an optional field seems ok.

I think exact is better because sealed in C# means you cannot inherit from it. I'd prefer either exact or strict personally.

Good point. This is the same in scala.

lewis6991 avatar Aug 11 '23 12:08 lewis6991