-
Notifications
You must be signed in to change notification settings - Fork 66
RFC: Nominal typing #123
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
base: master
Are you sure you want to change the base?
RFC: Nominal typing #123
Conversation
Co-authored-by: ariel <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What you are describing feels really close to opaque types / newtypes in other languages.
distinct type PositiveNumber = number | ||
|
||
function assertPositive(x: number): PositiveNumber | ||
assert(x >= 0, "not a positive number") | ||
return x :: PositiveNumber | ||
end | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is one allowed to coerce to a distinct type across boundaries? e.g. what's the expected behavior of:
-- Positive.lua
distinct type T = number
local exports = {}
function exports.new(n): T
assert(n >= 0, "not a positive number")
return x :: T
end
return exports
-- init.lua
local Positive = require("./Positive.lua")
local n: Positive.T = (-1) :: Positive.T
I think the implication of the RFC is that this is disallowed, as it's essential for using nominal types in the way you suggest.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quite the opposite; that would be allowed, especially if you consider "how would you annotate the type on n
if that were not allowed". Allowing that (-1) :: Positive.T
is essential for interop between structural and nominal typing, and the example there of using that to intentionally break the semantics of Positive
ends up falling under "obviously incorrect footguns that we can't really stop you doing".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quite the opposite; that would be allowed, especially if you consider "how would you annotate the type on n if that were not allowed."
It would be annotated the same? My expectation would be that one writes:
local Positive = require("./Positive.lua")
local n: Positive.T = Positive.new(1)
Positive.T
is opaque: we don't know the underlying structure, but we know it's compatible with any value that has the same time. At this point: n
cannot be used as a number
. You might imagine that Positive
also contains a function like:
function unwrap(n: T): number
return n :: number
end
... the example there of using that to intentionally break the semantics of Positive ends up falling under "obviously incorrect footguns that we can't really stop you doing".
IMO: it's going to be way less obvious in the context of a real world project. (-1) :: Positive.T
is obviously wrong, but { "foo", "bar" } :: React.Component
is not.
|
||
The name `distinct` was chosen as it clearly portrays the behaviour of a nominal type without requiring programmers to know what a `nominal` is. The Alternative section discusses some other potential names. | ||
|
||
Nominal types, despite the name, are not matched based on their name. If two source files define a nominal type with the same name, they are incompatible. Sharing a nominal type across files requires exporting it and requiring it in other places. There is no proposed method to avoid this and define two identical types in differing files instead. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO: this is fine if not desired. In languages like OCaml the convention is to give your data types the name t
/ T
such that you can write:
(* foobar.mli *)
type t
val make : int -> t
(* foobar.ml *)
type t = int
let make x = x
let n : Foobar.t = Foobar.make 42
local user_2 = user_1 + 1 -- Cannot perform arithmetic on nominal type | ||
``` | ||
|
||
In cases where this behaviour would be desired, the option remains to write `(user_1 :: number) + 1` following the previous rules regarding casting. `user_1 + user_1` would likewise be disallowed. This is done to avoid accidental loss of semantic meaning |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar question to the above: can I cast from distinct types outside of their defined modules?
This is a promising idea, but I'd like to see some more attention paid to how intersections and unions interact. Given this: distinct type Foo = { tag: "Foo", prop: number } How does this intersection simplify? These questions are pretty important because they relate to how nominal types would interact with refinements. |
In that instance, I would suggest |
That would be very unfortunate: distinct type Ok<T> = { ok: true, value: T}
distinct type Err<E> = { ok: false, error: E}
distinct type Result<T, E> = Ok<T> | Err<E>
function test(x: Result<number, string>)
if x.ok then
-- x' : Result<number, string> & { ok: true }
-- x' : never
end
end |
I can write more when I have time, but the tldr is that I think unions and intersections need to somehow strip off the newtypiness, but only in the ways we want and not in the ways we don't want. I'm sure a sensible set of rules is possible here. |
Would it work if nominals can be intersected with structures, resulting in either the nominal or never, but nominals can never be intersected with another nominal? I think that would solve the refinement issue without leading to weird behaviour. As a sidenote, for results you would want Ok and Err to be nominal, but Result shouldn't be, such that you could pass either an Ok or Err to anything that wanted a Result. With the suggestion in the first half of this comment, I would expect a nominal Result intersected with {ok:true} to inhabit never, but intersection with a structural Result to break it apart and result in just the Ok type (if that makes sense). |
I'm thinking about changing the intersection rules to the following, which I think resolves the issue regarding narrowing while retaining nominal semantics. Thoughts @andyfriesen?
|
In the absence of comments, I've updated the intersection semantics to the snippet above for now. |
Sorry for the late response. distinct type Ok<T> = { ok: true, value: T}
distinct type Err<E> = { ok: false, error: E}
distinct type ResultN<T, E> = Ok<T> | Err<E>
type Result<T, E> = Ok<T> | Err<E>
distinct type OkNS = Ok<number | string>
type ResultNS = OkNS | Err<string>
function foo(x: ResultN<number | string, string>)
if x.ok then
-- x : ResultN<number | string, string> & { ok: true }
-- x does not narrow to Ok<T>, as ResultN is nominal and cannot be discarded.
-- It however also doesn't narrow to never, as it is possible to satisfy { ok: true } as a constrained version of the nominal
end
end This requirement makes things really awkward. On one hand, we want interior code to be able to write I don't know what the solution is here, but it's starting to look like the solver is going to have to do something crazy like track two types: The nominal type of the symbol at a particular position, and the structural type that dictates how it can be used given its current refinements. |
I think the solution to that one would simply be a lint warning for if you make a nominal union, as a nominal union inherently comes with this confusion. In fact, potentially that |
That would essentially mean that nominal types do not participate in refinements ever. The following would also not work, for instance: distinct type MyTable = {
foo: Instance?
}
function foo(inst: Instance) end
function bar(tbl: MyTable)
if tbl.foo then
foo(tbl.foo) -- type error: Instance? is not compatible with Instance
end
end |
Based on the updated semantics, that would refine to |
Reading (and re-reading) this RFC makes me think of the issues that already exist dealing with the intersection of declare class Node
next: Node?
end local function getLast(n: Node)
if n.next then
-- We want `n` to have type `Node & { next: ~(false?) }` such that
-- `n.next` has type `Node? & ~(false?)`, or just `Node`
return getLast(n.next)
else
return n
end
end I don't quite recall what we've done for the new solver here, but I'd be surprised if we've gone as far as to "try" to normalize the results of
I agree that under the semantics proposed, nominal unions would never be warranted. It feels odd that we'd be adding a feature where, ahead of time, we must lint against what seems like a fairly normal syntax snippet. Going back to the motivation, the strongest item to me, given the semantics, is that it's pretty easy to end up with an unreadable type when hovering over a complex table type. I don't think a language feature is required to make progress there, though it might help. |
Rendered
Introduces the syntax
distinct type Foo = ...
to define nominal types.