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

Add eager inference annotation for polymorphic types #106

Open
wants to merge 29 commits into
base: master
Choose a base branch
from

Conversation

Ukendio
Copy link

@Ukendio Ukendio commented Mar 7, 2025

Ukendio and others added 26 commits March 5, 2025 23:32
automatic instantiation -> implicit instantiation
"one type" transitioned to "first usage" for clarity
It was pointed out in an external channel that this alternative was unnecessarily restrictive. The original intent was to allow flexibility for each usage of `T`.
test(1, "string", true) -- TypeError: Type `boolean` could not be converted into `number | string`
```

Notably, this behavior would be identical to other languages, such as typescript's `noinfer<T>`. This has the added drawback that the `!` syntax modifier would need to be barred from return types, as the return type holds no relevance to implicit instantiation.
Copy link

@Fireboltofdeath Fireboltofdeath Mar 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like a noinfer<T> type function should be listed more clearly as an alternative to the ! syntax, since it doesn't introduce any new keywords or symbols.

The current phrasing doesn't mention it as an alternative

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I'll add an update shortly to add noinfer clearly as an individual alternative.


## Motivation

The purpose of this feature is to develop syntax to prevent polymorphic types from widening into (e.g., number | string) when a function is implicitly instantiated with different argument types. E.g., `test(1, "a")`. In the following code, Luau's current solver infers `T` to be of a union type:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is "Luau's current solver?"

We have two products right now, Luau's Type Solver and Luau's New Type Solver. "type solver" is really a brand name coined by @andyfriesen, not something external folks necessarily recognize. Could also reasonably describe it as Luau's V2 type inference engine or something of that sort, honestly not entirely sure how we want to deal with the language around the two type inference systems in RFCs.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I considered this while editing the motivation paragraph, but was unsure if "type inference engine V2" was even the correct terminology to use. For now I'll switch the terminology there to "Luau's Type Inference Engine V2"?

local result = test(1, "string") -- inferred type `T`: number | string"
```

This behaviour can be useful in some cases but is undesirable when a polymorphic function is intended to constrain the input types to be consistent.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when a polymorphic function is intended to constrain the input types to be consistent.

This doesn't mean anything to me. The input type that was inferred in the example is already consistent. Every single instance of the generic T is indeed instantiated with the same consistent type, always, regardless of this RFC. That's just the semantics of polymorphism. I think the way you probably want to talk about your desire is something like... wanting to retain the exact type of the argument.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. I'll change it to "constrain the subsequent input types to be identical to the first usage." or something, couldn't think of anything better at the time.

## Design

We propose adding some symbol as a suffix (or prefix) that annotates the "eager" inference behaviour for a polymorphic type.
Subsequent usages of type `T` where `T` is "eager" would be ignored during instantiation.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description is misleading. They're not ignored during instantiation, you'll still replace each occurrence with the inferred type. The only thing you're proposing changes here, afaik, is that an annotated generic will opt call sites into inferring the exact type of the first argument for automatic instantiation.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right, "ignored" is the wrong term. I'll update it to "Subsequent uses of a polymorphic type T where T is "eager" will be inferred as the precise type of the first occurrence."


## Drawbacks

- Introduces a new syntax modifier `!`, which may lead to a symbol soup. However, `T!` doesn't seem too shabby next to `T?`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T! doesn't seem too shabby next to T?

I think this is actually a reasonably strong argument against this syntax: people might confuse it as having something to do with optionals or nil, and it does not at all.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah might lean onto using keywords or putting the annotation as a prefix instead !T.



### Keywords
Something like `<greedy T>` or `<strict T>` should also be considered if we want to reduce symbols. This idea has merit when considering the potential complexity of type aliases combined with `T!?`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T!?

This is also a very strong argument against this syntax.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though, is this actually possible under your proposal? You made ! a property of the definition at the generic, and you can't write optional in that context.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, T!? wouldn't be possible under the main proposal. It is however possible under the per-usage-binding alternative with unique syntax, I'll move it to a drawback of that alternative where it should be.

Copy link
Author

@Ukendio Ukendio Mar 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, T!? wouldn't be possible under the main proposal. It is however possible under the per-usage-binding alternative with unique syntax, I'll move it to a drawback of that alternative where it should be.

The problem is with inference not syntax as the annotation is defined at the definition, not the argument so there is no conflict in syntax. The problem would be that if a binding is required to be inferred by the first argument that binds to T annotated that it is an optional then the inference simply wouldn't work unless we changed the behaviour to infer the first non optional argument that binds to T to dictate the type.

I am curious to know how it worked before with eager inference by default, as that is likely what we want to mirror.

@gaymeowing
Copy link
Contributor

I think this is something better solved with type functions, because this seems really niche and isn't very explicit with its syntax.


## Summary

The RFC introduces a feature to annotate polymorphic function types to express that the first bind to `T` will be the one that sticks.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the behavior when a type references T more than once, such as an object?

function F<T!>(value: { a: T, b: T })

Additionally, what about when the object type is aliased (i.e there's no specific "first occurence")

type MyObject<T> = { a: T, b: T };

function F<T!>(value: MyObject<T>)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the behavior when a type references T more than once, such as an object?

It would behave in the same way as the old type solver which is taking the first definition that binds to T.

Additionally, what about when the object type is aliased (i.e there's no specific "first occurence")

Similar answer to the last point which is mirroring the old behaviour but in this case would be to go in the order of the fields. I don't know the exact heuristics for that is but I feel most of this could be answered with just saying it should do whatever the type solver did.

I will note that I do think something like noinfer<T> is a pretty good solution in these cases but I haven't personally prepared to make a case for that proposal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants