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

Bug: using interface in map as function parameter fails to compile #914

Closed
BLKTower opened this issue Jan 16, 2025 · 17 comments · Fixed by #918
Closed

Bug: using interface in map as function parameter fails to compile #914

BLKTower opened this issue Jan 16, 2025 · 17 comments · Fixed by #918
Labels
semantics Unexpected or unsound behaviors

Comments

@BLKTower
Copy link

BLKTower commented Jan 16, 2025

Seems using interface in map just causes compiler error:

local interface A
end

local record B is A
end

local c: B
local d: {string: B}

local function Test_1(a: A)
end

local function Test_2(a: {string: A})
end

Test_1(c) -- Compiles fine
Test_2(d) -- Error: argument 1: in map value: B is not a A

If changed map to array, both compile fine:

local interface A
end

local record B is A
end

local c: B
local d: {B}

local function Test_1(a: A)
end

local function Test_2(a: {A})
end

Test_1(c) -- Compiles fine
Test_2(d) -- Compiles fine

Desired behavior: Test_2 compiles without error.

@BLKTower
Copy link
Author

BLKTower commented Jan 16, 2025

local interface A
end

local record B is A
end

local c: B
local d: {B}

local function Test_1(a: A)
end

local function Test_2(a: {A})
end

Test_1(c) -- Compiles fine
Test_2(d) -- Compiles fine

And it seems not bugged for all types. For example, if nested in array, both compile without error.

@BLKTower
Copy link
Author

BLKTower commented Jan 16, 2025

local interface A
end

local record C is A
end

local c: C

local function Test(a: {string: A})
end

Test(c) --- Compiles fine. Why?

And as I kept testing, more weird stuff happened... I have no idea why this is valid.

@hishamhm
Copy link
Member

Can you test it with the branch from PR #911 ? thank you!

@BLKTower
Copy link
Author

Can you test it with the branch from PR #911 ? thank you!

Hi, @hishamhm , thanks for the rapid replay. I just tested with both #911 commit and generics-regressions branch latest commit. Unfortunately, both issues I reported above persist.

@BLKTower
Copy link
Author

Can you test it with the branch from PR #911 ? thank you!

Sorry, the test case I presented above is misleading, this is actually nothing to do with generic. Seems using interface in map just causes problem.

local interface A
end

local record B is A
end

local c: B
local d: {string: B}

local function Test_1(a: A)
end

local function Test_2(a: {string: A})
end

Test_1(c) -- Compiles fine
Test_2(d) -- Error: argument 1: in map value: B is not a A

@BLKTower BLKTower changed the title Bug: interface with generic nested in other type fails to compile Bug: using interface in map as function parameter fails to compile Jan 17, 2025
@BLKTower
Copy link
Author

@hishamhm Hi, any updates on this issue?

@hishamhm
Copy link
Member

I haven't been able to take a closer look at this yet. Thank you for the test cases!

@hishamhm
Copy link
Member

hishamhm commented Jan 22, 2025

This happens because, as of today, Teal uses invariant typing for maps — that is, map keys and values types must be identical. This is the rationale why:

local interface Animal
   name: string
end

local interface  Bird is Animal
end

local interface Cat is Animal
end

local cucko: Bird = { name = "Cucko" }
local dodos: {Animal: Bird} = {}

local function Test_1(a: Animal)
   print("hello, " .. a.name)
end

local function Test_2(a: {string: Animal})
   local f: Cat = { name = "Felix" }
   a["felix"] = f
end

Test_1(cucko) -- Compiles fine
Test_2(dodos) -- If this compiled, we would have stored a cat in a map of birds!

The two possible solutions for this problem are:

  1. make the language more complex so that the programmer uses extra syntax to specify that the subtyping relation in that particular instance is safe (i.e. that map will be read-only)
  2. make the language less safe by allowing types in map keys and values with subtyping relations even if mistakes like the one in Test_2 above could happen.

Solution 1 is what Java does for managing generics with the ? extends and ? super syntax. Solution 2 is what we already do for function types (IIRC it's the same thing TypeScript does). I think, for the current incarnation of Teal, Solution 2 is probably the way to go. It is less safe but it is consistent with how the rest of the language behaves, at least for now.

@hishamhm hishamhm added the semantics Unexpected or unsound behaviors label Jan 22, 2025
@hishamhm
Copy link
Member

(also note that there is an existing workaround which makes your test case compile:

local function Test_2<T is A>(a: {string: T})

that makes the map value type specialize at the call site, and that makes the invariant check pass. However, this still allows the "store cat in the map of birds" problem I mentioned before. So in that regard we're already being unsafe, which is a further argument for just going with solution 2.

@BLKTower
Copy link
Author

BLKTower commented Jan 22, 2025

@hishamhm Thanks for your clarification. Personally, I am also in favor of solution 2. If we already know Test_2 function will add a cat in the map, then Test_2 should not declare {string: Animal} as the parameter, just use {string: Cat}. I think it is the users' responsibility to avoid such problems.
And I think my actual complaint here is the inconsistency between map and array. I'm even ok with teal doesn't support the above map usage, but in this case, array should also be invalid. Passing a {Bird} to a function takes {Animals} argument and adds a Cat to the array has the exact same "store cat in the map of birds" problem as map does. I think at least teal should behave the same for both array and map.
Also, could you take a look at the weird issue I posted above? Really appreciate it!

hishamhm added a commit that referenced this issue Jan 23, 2025
@catwell
Copy link
Contributor

catwell commented Jan 23, 2025

@BLKTower regarding your "weird issue" here is a simple version:

local record R
end

local x: R = {}

local function test(a: {string: R})
end

test(x)

This works, but if you add any field to the record R it does not.

This makes sense because a record with no field is basically an empty map, and so it matches the type of the argument.

Similarly this works (as intended):

local record R
end

local record R2
    foo: R
end

local x: R2 = {}

local function test(a: {string: R})
end

test(x)

@BLKTower
Copy link
Author

@catwell Hi, thanks for your explanation. Though, I still feel such by design is kind of weird. I cannot think of any other strong type languages (correct me if I'm wrong) that treat a class-like type without any fields as a dictionary. IMHO, a record without any fields should still be a unique record type, and treating it as a map type is kind of compromising the core idea of teal, adding type to lua.

@catwell
Copy link
Contributor

catwell commented Jan 23, 2025

Not sure how I feel about the fact that it works transparently, but what about my second example? Would you also want x not to match the type of the argument of test? Because the empty record is the limit case of this.

@BLKTower
Copy link
Author

Not sure how I feel about the fact that it works transparently, but what about my second example? Would you also want x not to match the type of the argument of test? Because the empty record is the limit case of this.

IMHO, yes, I do want x not to match the type of the argument of test.

local record R
end
local record R2
    foo: R
end
local x: R2 = {}
local function test(a: {string: R})
    a["foo2"] = {}
end
test(x)
local r = x.foo2 -- Error: invalid key 'foo2' in record 'x' of type R2

It's valid to add an entry foo2 to x in function test, but this seems useless to me since once out of the scope of test, foo2 will be inaccessible.
TBH, I cannot think of any use case for this, and I'd like the compiler to just throw me an error. If I need a function to manipulate x, I should just create a test_2(a: R2).

@catwell
Copy link
Contributor

catwell commented Jan 23, 2025

I thought about it a bit and I think it should probably not work without a cast, because then test would be allowed to modify a in a way that breaks its type.

@BLKTower your remark about other languages made me think of this because that is the reason why in TypeScript interface {foo: string} is compatible with type {[k: string]: any} but not with {[k: string]: string}.

@BLKTower
Copy link
Author

I thought about it a bit and I think it should probably not work without a cast, because then test would be allowed to modify a in a way that breaks its type.

@BLKTower your remark about other languages made me think of this because that is the reason why in TypeScript interface {foo: string} is compatible with type {[k: string]: any} but not with {[k: string]: string}.

I see. TIL, I can do {[k: string]: any} in ts, lol. In this case, I guess what I need is to look at the code from a different mindset.

@catwell
Copy link
Contributor

catwell commented Jan 24, 2025

Since this issue is closed and about a different problem I think you should create a new ticket for this one so @hishamhm can track it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
semantics Unexpected or unsound behaviors
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants