From 73d879d9031482c79d0359ad3ac532e9d0e8e12f Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:39:36 +0700 Subject: [PATCH 01/35] Introduce RFC for unique types in Luau Add RFC for unique types in Luau, detailing their syntax, behavior, and use cases. --- docs/unique-types.md | 227 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/unique-types.md diff --git a/docs/unique-types.md b/docs/unique-types.md new file mode 100644 index 000000000..280d58b72 --- /dev/null +++ b/docs/unique-types.md @@ -0,0 +1,227 @@ +# Unique Types +--- +## Summary +--- +This RFC proposes adding support for unique types to luau. + +Unique types are an extension to the type checker, and make no changes to runtime semantics. + +## Motivation +--- +Since Luau uses structural typing, there is no way to make a primitive distinct. If there are two types PlayerId and AssetId and they are both strings, the type checker allows a PlayerId to be passed into a function expecting AssetId because they are both just string. + +Current workarounds like tagging (string & { _tag: "PlayerId" }) are messy and confuse autocomplete. + +Unique types solve this by being able to be composed with existing types to make them completely unique, kind of like a tag. +This relies on the behavior of intersections in that an intersection T can only be cast into another intersection U if T is the same/is a subtype of U +A structure that is an intersection between some type T and a unique type U will not be able to be cast into another structure that is an intersection between the same type T and another unique type V + +## Design +--- +The proposed syntax to create a unique type is to define it using `unique type TypeName`, it does not have an = symbol after it because all unique types are are opaque unique types, they do not contain any additional information beyond that. + +The name `unique` was chosen as it clearly conveys that this specific type is unique and is not the same as any other type +Unique types of the same name defined in different files will, of course, still be unique as their own "primitive" type, and cannot be cast to eachother. + +### Behavior with intersections + +Since unique types simply act as a unique opaque type, this means intersecting them is quite trivial and isn't much different from intersecting other primitive types such as `unknown`. + +### Behavior with literals + +When assigning a literal value to a variable, a cast will not be implicitly performed. It is expected that unique types will almost exclusively be generated within APIs, rather than written as literals. +Illustrated in code: + +```luau +unique type _useridtag +type UserId = _useridtag & number + +local user1: UserId = 1 -- doesnt work, error +local user2: UserId = 2 :: UserId -- works! +``` + +It's important to note that the only reason this works is because unique types are opaque, and should work similarly to `unknown` wherein it'll "inherit" anything it's composed with + +### Casting semantics +Unique types can be casted to other unique types, or other structural types provided the types are compatible in a structural manner. That is to say: + +```luau +unique type Vector + type Vec2 = Vector & { x: number, y: number } + type Vec3 = Vector & { x: number, y: number, z: number } + +local vec2_1 = { x=1, y=1 } :: Vec2 +local vec3_1 = { x=1, y=1, z=2 } :: Vec3 + +local vec2_2: Vec2 = vec3_1 :: Vec2 -- Works, "x" and "y" are present, which is all that's required +local vec3_2: Vec3 = vec2_1 :: Vec3 -- Doesnt work, "z" is missing from the type +``` + +Again, it's important to note that the only reason this works is because unique types are opaque, and should work similarly to `unknown` wherein it'll "inherit" anything it's composed with + +### Enum-like behavior + +Because unique types are used as tags within intersections, this means you can get behavior similar to enums + +```luau +unique type SomethingType + type Item = SomethingType & "Item" + type Weapon = SomethingType & "Weapon" + +local function getSomething(ty: SomethingType) + +getSomething("Weapon") -- type error: expected SomethingType, got "Weapon" +getSomething("Weapon" :: Item) -- errors, could not convert "Weapon" into "Item" +getSomething("Item" :: Item) -- works! +getSomething("Weapon" :: Weapon) -- works! +``` + +### Mutually inclusive types + +A unique type can be composed within multiple type definitions to create mutually inclusive types which can only be cast to eachother or the tag but nothing else + +```luau +unique type _coordtag + type CoordX = _coordtag & number + type CoordY = _coordtag & number + type CoordZ = _coordtag & number + +-- These can all be converted to each other +local function swapAxes(x: CoordX, y: CoordY): (CoordY, CoordX) + return x :: CoordY, y :: CoordX +end + +local function promoteToZ(x: CoordX): CoordZ + return x :: CoordZ +end + +local x: CoordX = 10 :: CoordX +local y: CoordY = 20 :: CoordY + +local newY, newX = swapAxes(x, y) -- Valid +local z: CoordZ = promoteToZ(x) -- Valid + +-- But they're still isolated from regular numbers +local regular: number = x -- error: CoordX is not assignable to number + +-- And from other unique type families +unique type _othertag +type OtherId = _othertag & number +local other: OtherId = x -- error: Different unique tags +``` + +### Operations on unique types + +Since unique types themselves act like `unknown`, this means any sort of operations on a unique type itself will error and is invalid. +However, if you compose a unique type, it'll have all the operations of the types you're composing it with. + +```luau +unique type T +type U = T & string + +local t: T +local u: U + +print(t .. "hi") -- doesnt work, t doesnt have __concat +print(u .. "hi") -- Works, type "string" does have __concat +``` + +### Refinement behavior + +Unique types can be refined through type guards and pattern matching based on their underlying structural types. + +```luau +unique type _itemtag + type ItemId = _itemtag & string + type ItemData = _itemtag & { id: string, name: string } + +local function processItem(item: ItemId | ItemData) + if type(item) == "string" then + -- item is refined to ItemId + print("Item ID: " .. item) + else + -- item is refined to ItemData + print("Item: " .. item.name) + end +end +Unique types work with discriminated unions: +luauunique type EventType +type ClickEvent = EventType & { kind: "click", x: number, y: number } +type KeyEvent = EventType & { kind: "key", code: string } + +type Event = ClickEvent | KeyEvent + +local function handleEvent(event: Event) + if event.kind == "click" then + -- event is refined to ClickEvent + print("Click at", event.x, event.y) + end +end +``` + +When multiple unique types are intersected in a discriminated union, refinement preserves only the unique types that are compatible with all variants in the refined branch: + +```luau +unique type Validated +unique type Sanitized +unique type EventType + +type ValidatedClick = Validated & EventType & { kind: "click", x: number } +type SanitizedKey = Sanitized & EventType & { kind: "key", code: string } +type ValidatedAndSanitizedScroll = Validated & Sanitized & EventType & { kind: "scroll", delta: number } + +type Event = ValidatedClick | SanitizedKey | ValidatedAndSanitizedScroll + +local function handleEvent(event: Event) + if event.kind == "click" or event.kind == "scroll" then + -- event is refined to: Validated & EventType & (ClickEvent | ScrollEvent) + -- Sanitized is discarded because ValidatedClick doesn't have it + processValidated(event) -- Works: both variants have Validated + end +end +``` + +# Drawbacks +--- +- **Verbose type signatures**: Types that compose unique types will have an ugly type signature (for example `_uniquetype & string`) which means that developers that want a nice clean identifier signature for their unique types (for example `PlayerId`) will not be able to achieve it without risking type safety by directly using unique types instead of composing them, as illustrated here: +```luau +unique type PlayerId -- No type safety, but identifier type signature (signature is just the name which is PlayerId) +unique type _playerid +type PlayerId = _playerid & number -- Has type safety, but ugly type signature +``` + +- **Naming convention burden**: The recommended pattern of using a private unique type tag (e.g., `_playerid`) composed into a public type alias (e.g., `PlayerId`) creates a burden where developers must choose naming conventions for their tags. This could lead to inconsistency across codebases. + +- **Migration complexity**: Existing codebases that have string or number types for IDs will need explicit casts everywhere to convert to unique types, which could be a significant migration effort for large projects. + +- **Error message clarity**: Type errors involving unique types may be confusing, especially when the error shows the full intersection type (e.g., `_playerid & number`) rather than the friendly alias (`PlayerId`). This could make debugging harder for developers unfamiliar with unique types. + +- **Complexity with multiple unique tags**: Code using multiple intersected unique types (e.g., `Validated & Sanitized & EventType & { ... }`) can become difficult to read and reason about, especially when determining which unique tags are preserved through refinement. + +# Alternatives +--- + +### Just use tables +My example of distinct UserId and AssetId types could instead be written as + +```luau +type UserId = { userId: number } +type AssetId = { assetId: number } +``` + +This works in the current system and makes these types incompatible. For the vectors example, the following snippet would produce the required incompatible types: + +```luau +type Vec2 = { x: number, y: number, __isVec2: number } +type Vec3 = { x: number, y: number, z: number, __isVec3: number } +``` + +A helper type could be used to perform this automatically, such as the following: + +```luau +type Tagged = T & { __tag: Tag } +type UserId = Tagged +type AssetId = Tagged +``` + +In all of these cases, the types are no longer zero-cost at runtime, and in the cases where casting between "nominal" types is desired, it also incurs a runtime cost. The Tagged option does allow runtime introspection, however nothing in this RFC would disallow use of that existing pattern when desired. From 72ca62cb9082c1df3ef156264382d5e54db1c5df Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:42:47 +0700 Subject: [PATCH 02/35] Improve clarity on unique types and intersections Clarified explanation of unique types and intersections. --- docs/unique-types.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 280d58b72..23dac841f 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -13,8 +13,8 @@ Since Luau uses structural typing, there is no way to make a primitive distinct. Current workarounds like tagging (string & { _tag: "PlayerId" }) are messy and confuse autocomplete. Unique types solve this by being able to be composed with existing types to make them completely unique, kind of like a tag. -This relies on the behavior of intersections in that an intersection T can only be cast into another intersection U if T is the same/is a subtype of U -A structure that is an intersection between some type T and a unique type U will not be able to be cast into another structure that is an intersection between the same type T and another unique type V +This relies on the behavior of intersections in that an intersection T can only be cast into another intersection U if T is the same/is a subtype of U. +A structure that is an intersection between some type T and a unique type U will not be able to be cast into another structure that is an intersection between the same type T and another unique type V. ## Design --- From 9d995d6b18d47e34716d25b8ea559f8272a8172d Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:44:52 +0700 Subject: [PATCH 03/35] added a period (#1) --- docs/unique-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 23dac841f..af9b49d2c 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -20,7 +20,7 @@ A structure that is an intersection between some type T and a unique type U will --- The proposed syntax to create a unique type is to define it using `unique type TypeName`, it does not have an = symbol after it because all unique types are are opaque unique types, they do not contain any additional information beyond that. -The name `unique` was chosen as it clearly conveys that this specific type is unique and is not the same as any other type +The name `unique` was chosen as it clearly conveys that this specific type is unique and is not the same as any other type. Unique types of the same name defined in different files will, of course, still be unique as their own "primitive" type, and cannot be cast to eachother. ### Behavior with intersections From cad3cd0809cc30380154cc36d8865c85101374c0 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:50:20 +0700 Subject: [PATCH 04/35] Refactor unique type definitions in unique-types.md Updated the syntax for defining unique types in Luau. --- docs/unique-types.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index af9b49d2c..0b35d0268 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -144,10 +144,14 @@ local function processItem(item: ItemId | ItemData) print("Item: " .. item.name) end end +``` + Unique types work with discriminated unions: -luauunique type EventType -type ClickEvent = EventType & { kind: "click", x: number, y: number } -type KeyEvent = EventType & { kind: "key", code: string } + +```luau +unique type EventType + type ClickEvent = EventType & { kind: "click", x: number, y: number } + type KeyEvent = EventType & { kind: "key", code: string } type Event = ClickEvent | KeyEvent From cbde0b64eaca8fb08fa72eb9fce68a9697389c9c Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:05:51 +0700 Subject: [PATCH 05/35] Added export semantics --- docs/unique-types.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index 0b35d0268..7313e7d81 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -185,6 +185,13 @@ local function handleEvent(event: Event) end ``` +### Exporting unique types + +Since it would be nice for unique types to be able to be used outside of the file it was declared in, we need to make the `export` keyword compatible with the `unique type TypeName` syntax. + +The way I propose would be to simply force the syntax of `export unique type TypeName` to be the way to export unique types, for example `unique export type TypeName` shouldn't work. +This may be a bit verbose however it isn't any longer and is more clear and consistent with existing syntax (`export type function`) than any alternatives such as attributes or symbols. + # Drawbacks --- - **Verbose type signatures**: Types that compose unique types will have an ugly type signature (for example `_uniquetype & string`) which means that developers that want a nice clean identifier signature for their unique types (for example `PlayerId`) will not be able to achieve it without risking type safety by directly using unique types instead of composing them, as illustrated here: From b8cb27e3bd091c8ce8240bc6454a520f9aecd3c7 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:19:53 +0700 Subject: [PATCH 06/35] Added type function semantics --- docs/unique-types.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index 7313e7d81..9d5fe5ae4 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -192,6 +192,26 @@ Since it would be nice for unique types to be able to be used outside of the fil The way I propose would be to simply force the syntax of `export unique type TypeName` to be the way to export unique types, for example `unique export type TypeName` shouldn't work. This may be a bit verbose however it isn't any longer and is more clear and consistent with existing syntax (`export type function`) than any alternatives such as attributes or symbols. +### Type function semantics + +Due to the nature of unique types, there would be no way to construct unique types in type functions. + +However, since you should be able to input unique types into type functions, or use it as an upvalue, the following are some semantic rules for unique `type` objects: + +- Calling `type:__eq()` or using the `==` operator on a unique `type` object on any type other than itself should return `false`. +- There should be a new valid string input to `type:is()`, which is `"unique"`. Illustrated in code: + ```luau + type function hi(T) + if T:is("unique") then + print("t is unique!") + else + print("t is normal :(") + end + return T + end + ``` +- Unique types that are passed into type functions should have a `.tag` property set to `"unique"` too. + # Drawbacks --- - **Verbose type signatures**: Types that compose unique types will have an ugly type signature (for example `_uniquetype & string`) which means that developers that want a nice clean identifier signature for their unique types (for example `PlayerId`) will not be able to achieve it without risking type safety by directly using unique types instead of composing them, as illustrated here: From f5b1f7c8b6d1154c813d0f128de2547526579af8 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:53:09 +0700 Subject: [PATCH 07/35] Added no other language has done this drawback --- docs/unique-types.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index 9d5fe5ae4..ef6200c7d 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -229,6 +229,8 @@ type PlayerId = _playerid & number -- Has type safety, but ugly type signature - **Complexity with multiple unique tags**: Code using multiple intersected unique types (e.g., `Validated & Sanitized & EventType & { ... }`) can become difficult to read and reason about, especially when determining which unique tags are preserved through refinement. +- **No other language has done this**: The concept of a subset of nominal typing (which is what unique types are) being implemented as first-class syntax hasn't been done in other mainstream languages, which can hinder the adoption of this feature. + # Alternatives --- From 4c45324e1b7ef4ddf829345bce70e28acfb4a1af Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:49:40 +0700 Subject: [PATCH 08/35] Changed semantics and syntax to work with subtypes instead of carrying no metadata --- docs/unique-types.md | 182 ++++++++++--------------------------------- 1 file changed, 43 insertions(+), 139 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index ef6200c7d..4a7076795 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -12,147 +12,87 @@ Since Luau uses structural typing, there is no way to make a primitive distinct. Current workarounds like tagging (string & { _tag: "PlayerId" }) are messy and confuse autocomplete. -Unique types solve this by being able to be composed with existing types to make them completely unique, kind of like a tag. -This relies on the behavior of intersections in that an intersection T can only be cast into another intersection U if T is the same/is a subtype of U. -A structure that is an intersection between some type T and a unique type U will not be able to be cast into another structure that is an intersection between the same type T and another unique type V. +Unique types solve this by being able to be created with a supertype, giving them additional type information aside from being completely unique. +A unique type will be able to be cast to its supertype, but not to other unique types or types that are not its supertype. ## Design --- -The proposed syntax to create a unique type is to define it using `unique type TypeName`, it does not have an = symbol after it because all unique types are are opaque unique types, they do not contain any additional information beyond that. +The proposed syntax to create a unique type is to define it using `type TypeName: Supertype`, the unique type `TypeName` will be defined as having a supertype `Supertype`, defined after the colon. A unique type with no supertype is not allowed as that type would never exist and is "uninhabited". -The name `unique` was chosen as it clearly conveys that this specific type is unique and is not the same as any other type. -Unique types of the same name defined in different files will, of course, still be unique as their own "primitive" type, and cannot be cast to eachother. +### Behavior with autocomplete -### Behavior with intersections - -Since unique types simply act as a unique opaque type, this means intersecting them is quite trivial and isn't much different from intersecting other primitive types such as `unknown`. +The autocomplete of a unique type should inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. ### Behavior with literals -When assigning a literal value to a variable, a cast will not be implicitly performed. It is expected that unique types will almost exclusively be generated within APIs, rather than written as literals. +When assigning a literal value to a variable, a cast will be implicitly performed. A unique type cannot be cast to another unique type, however can be cast to types it is subtype of (defined by the type expression after the : in the unique types declaration) Illustrated in code: ```luau -unique type _useridtag -type UserId = _useridtag & number - -local user1: UserId = 1 -- doesnt work, error -local user2: UserId = 2 :: UserId -- works! -``` - -It's important to note that the only reason this works is because unique types are opaque, and should work similarly to `unknown` wherein it'll "inherit" anything it's composed with - -### Casting semantics -Unique types can be casted to other unique types, or other structural types provided the types are compatible in a structural manner. That is to say: - -```luau -unique type Vector - type Vec2 = Vector & { x: number, y: number } - type Vec3 = Vector & { x: number, y: number, z: number } - -local vec2_1 = { x=1, y=1 } :: Vec2 -local vec3_1 = { x=1, y=1, z=2 } :: Vec3 +type UserId: number +type PlaceId: number -local vec2_2: Vec2 = vec3_1 :: Vec2 -- Works, "x" and "y" are present, which is all that's required -local vec3_2: Vec3 = vec2_1 :: Vec3 -- Doesnt work, "z" is missing from the type +local user1: UserId = 2 -- works! UserId is subtype of number +local user2: UserId = 12323 :: PlaceId -- type error: could not convert PlaceId into UserId ``` -Again, it's important to note that the only reason this works is because unique types are opaque, and should work similarly to `unknown` wherein it'll "inherit" anything it's composed with - -### Enum-like behavior - -Because unique types are used as tags within intersections, this means you can get behavior similar to enums - -```luau -unique type SomethingType - type Item = SomethingType & "Item" - type Weapon = SomethingType & "Weapon" - -local function getSomething(ty: SomethingType) +### Behavior with intersections -getSomething("Weapon") -- type error: expected SomethingType, got "Weapon" -getSomething("Weapon" :: Item) -- errors, could not convert "Weapon" into "Item" -getSomething("Item" :: Item) -- works! -getSomething("Weapon" :: Weapon) -- works! -``` +Using a unique type in an intersection would result in `*error type*`, as a unique type simply denotes a distinct type that is a subtype of something else, and intersecting with that shouldn't be allowed. -### Mutually inclusive types +### Behavior with unions -A unique type can be composed within multiple type definitions to create mutually inclusive types which can only be cast to eachother or the tag but nothing else +Using a unique type in a union would work, illustrated in something like: ```luau -unique type _coordtag - type CoordX = _coordtag & number - type CoordY = _coordtag & number - type CoordZ = _coordtag & number - --- These can all be converted to each other -local function swapAxes(x: CoordX, y: CoordY): (CoordY, CoordX) - return x :: CoordY, y :: CoordX -end - -local function promoteToZ(x: CoordX): CoordZ - return x :: CoordZ -end - -local x: CoordX = 10 :: CoordX -local y: CoordY = 20 :: CoordY - -local newY, newX = swapAxes(x, y) -- Valid -local z: CoordZ = promoteToZ(x) -- Valid +type UserIdNumber: number +type UserIdString: string --- But they're still isolated from regular numbers -local regular: number = x -- error: CoordX is not assignable to number +local function getData(id: UserIdNumber | UserIdString) end --- And from other unique type families -unique type _othertag -type OtherId = _othertag & number -local other: OtherId = x -- error: Different unique tags +local data = getData(1234) -- This makes sense, UserIdNumber | UserIdString reads as "UserIdNumber, a type that is a subtype of number, or UserIdString, a type that is a subtype of string". ``` -### Operations on unique types - -Since unique types themselves act like `unknown`, this means any sort of operations on a unique type itself will error and is invalid. -However, if you compose a unique type, it'll have all the operations of the types you're composing it with. +### Casting semantics +Unique types can be casted to other unique types, or other structural types provided the types are compatible in a structural manner. That is to say: ```luau -unique type T -type U = T & string +type Vec2: { x: number, y: number } +type Vec3: { x: number, y: number, z: number } -local t: T -local u: U +local vec2_1 = { x=1, y=1 } +local vec3_1 = { x=1, y=1, z=2 } -print(t .. "hi") -- doesnt work, t doesnt have __concat -print(u .. "hi") -- Works, type "string" does have __concat +local vec2_2: Vec2 = vec3_1 -- Works, "x" and "y" are present, which is all that's required +local vec3_2: Vec3 = vec2_1 -- Doesnt work, "z" is missing from the type +local vec3_3: Vec2 = vec3_2 -- Doesnt work, Vec3 cannot be cast into Vec2 despite the fact that Vec2 is a valid subtype of Vec3 ``` ### Refinement behavior -Unique types can be refined through type guards and pattern matching based on their underlying structural types. +Unique types can be refined through type guards and pattern matching based on their supertype. ```luau -unique type _itemtag - type ItemId = _itemtag & string - type ItemData = _itemtag & { id: string, name: string } +type ItemId: string +type ItemData: {data: ItemId, name: string} local function processItem(item: ItemId | ItemData) if type(item) == "string" then - -- item is refined to ItemId + -- item is refined to ItemId as the only type that is a subtype of string is ItemId, and to satisfy type(item) == "string" the type must be a subtype of string print("Item ID: " .. item) else - -- item is refined to ItemData + -- item is refined to ItemData as that's the only other member of the union that's not a subtype of string print("Item: " .. item.name) end end ``` -Unique types work with discriminated unions: +Unique types work with discriminated unions, however if a unique type itself is a discriminated union, it will not be able to be decomposed into the correct component as unique types due to the "atomic" nature of nominal types: ```luau -unique type EventType - type ClickEvent = EventType & { kind: "click", x: number, y: number } - type KeyEvent = EventType & { kind: "key", code: string } - +type ClickEvent: {kind: "click", x: number, y: number} +type KeyEvent: {kind: "key", code: string} +type UEvent: ClickEvent | KeyEvent type Event = ClickEvent | KeyEvent local function handleEvent(event: Event) @@ -161,37 +101,17 @@ local function handleEvent(event: Event) print("Click at", event.x, event.y) end end -``` - -When multiple unique types are intersected in a discriminated union, refinement preserves only the unique types that are compatible with all variants in the refined branch: -```luau -unique type Validated -unique type Sanitized -unique type EventType - -type ValidatedClick = Validated & EventType & { kind: "click", x: number } -type SanitizedKey = Sanitized & EventType & { kind: "key", code: string } -type ValidatedAndSanitizedScroll = Validated & Sanitized & EventType & { kind: "scroll", delta: number } - -type Event = ValidatedClick | SanitizedKey | ValidatedAndSanitizedScroll - -local function handleEvent(event: Event) - if event.kind == "click" or event.kind == "scroll" then - -- event is refined to: Validated & EventType & (ClickEvent | ScrollEvent) - -- Sanitized is discarded because ValidatedClick doesn't have it - processValidated(event) -- Works: both variants have Validated - end +local function handleUEvent(event: UEvent) + if event.kind == "click" then + -- UEvent is a subtype of ClickEvent | KeyEvent, which means UEvent is either one of these. + -- However, UEvent is a unique type, which means it cannot be decomposed any further + -- Due to this, this means that the "event" variable will have 0 autocomplete (opaque) because it's unclear which one it's supposed to be + -- So here, no refinement occurs and event remains as UEvent + end end ``` -### Exporting unique types - -Since it would be nice for unique types to be able to be used outside of the file it was declared in, we need to make the `export` keyword compatible with the `unique type TypeName` syntax. - -The way I propose would be to simply force the syntax of `export unique type TypeName` to be the way to export unique types, for example `unique export type TypeName` shouldn't work. -This may be a bit verbose however it isn't any longer and is more clear and consistent with existing syntax (`export type function`) than any alternatives such as attributes or symbols. - ### Type function semantics Due to the nature of unique types, there would be no way to construct unique types in type functions. @@ -214,22 +134,6 @@ However, since you should be able to input unique types into type functions, or # Drawbacks --- -- **Verbose type signatures**: Types that compose unique types will have an ugly type signature (for example `_uniquetype & string`) which means that developers that want a nice clean identifier signature for their unique types (for example `PlayerId`) will not be able to achieve it without risking type safety by directly using unique types instead of composing them, as illustrated here: -```luau -unique type PlayerId -- No type safety, but identifier type signature (signature is just the name which is PlayerId) -unique type _playerid -type PlayerId = _playerid & number -- Has type safety, but ugly type signature -``` - -- **Naming convention burden**: The recommended pattern of using a private unique type tag (e.g., `_playerid`) composed into a public type alias (e.g., `PlayerId`) creates a burden where developers must choose naming conventions for their tags. This could lead to inconsistency across codebases. - -- **Migration complexity**: Existing codebases that have string or number types for IDs will need explicit casts everywhere to convert to unique types, which could be a significant migration effort for large projects. - -- **Error message clarity**: Type errors involving unique types may be confusing, especially when the error shows the full intersection type (e.g., `_playerid & number`) rather than the friendly alias (`PlayerId`). This could make debugging harder for developers unfamiliar with unique types. - -- **Complexity with multiple unique tags**: Code using multiple intersected unique types (e.g., `Validated & Sanitized & EventType & { ... }`) can become difficult to read and reason about, especially when determining which unique tags are preserved through refinement. - -- **No other language has done this**: The concept of a subset of nominal typing (which is what unique types are) being implemented as first-class syntax hasn't been done in other mainstream languages, which can hinder the adoption of this feature. # Alternatives --- From 02cd7fdca2dd873fc82a2da62d5b12f8ba049ecb Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:13:28 +0700 Subject: [PATCH 09/35] Added generic semantics & changed casting to be explicit --- docs/unique-types.md | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 4a7076795..b0969a6c8 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -25,15 +25,17 @@ The autocomplete of a unique type should inherit from its defined supertype, as ### Behavior with literals -When assigning a literal value to a variable, a cast will be implicitly performed. A unique type cannot be cast to another unique type, however can be cast to types it is subtype of (defined by the type expression after the : in the unique types declaration) +When assigning a literal value to a variable, a cast will NOT be implicitly performed. A unique type cannot be cast to another unique type, however can be cast to types it is subtype of (defined by the type expression after the : in the unique types declaration) Illustrated in code: ```luau type UserId: number type PlaceId: number -local user1: UserId = 2 -- works! UserId is subtype of number -local user2: UserId = 12323 :: PlaceId -- type error: could not convert PlaceId into UserId +local user1: UserId = 2 -- Doesnt work, must cast first +local user2 = 2 :: UserId -- Works! UserId is a subtype of number and its being typecast +local user3 = "2" :: UserId -- Doesnt work, string is not a supertype of UserId +local user2: UserId = 12323 :: PlaceId -- Doesnt work, could not convert PlaceId into UserId ``` ### Behavior with intersections @@ -50,7 +52,7 @@ type UserIdString: string local function getData(id: UserIdNumber | UserIdString) end -local data = getData(1234) -- This makes sense, UserIdNumber | UserIdString reads as "UserIdNumber, a type that is a subtype of number, or UserIdString, a type that is a subtype of string". +local data = getData(1234 :: UserIdNumber) -- This makes sense, UserIdNumber | UserIdString reads as "UserIdNumber, a type that is a subtype of number, or UserIdString, a type that is a subtype of string". ``` ### Casting semantics @@ -60,14 +62,16 @@ Unique types can be casted to other unique types, or other structural types prov type Vec2: { x: number, y: number } type Vec3: { x: number, y: number, z: number } -local vec2_1 = { x=1, y=1 } -local vec3_1 = { x=1, y=1, z=2 } +local vec2_1 = { x=1, y=1 } :: Vec2 +local vec3_1 = { x=1, y=1, z=2 } :: Vec3 local vec2_2: Vec2 = vec3_1 -- Works, "x" and "y" are present, which is all that's required local vec3_2: Vec3 = vec2_1 -- Doesnt work, "z" is missing from the type local vec3_3: Vec2 = vec3_2 -- Doesnt work, Vec3 cannot be cast into Vec2 despite the fact that Vec2 is a valid subtype of Vec3 ``` +However, an annotation will NOT perform an implicit cast on the value. To assign a value to a variable of a unique type, you must typecast it first. This is so the programmer is required to pass explicit intent that they want this value to be of this unique type + ### Refinement behavior Unique types can be refined through type guards and pattern matching based on their supertype. @@ -112,6 +116,29 @@ local function handleUEvent(event: UEvent) end ``` +### Generic arguments semantics + +To accomodate usage of generics, unique types are able to declare a list of generic arguments using the `type TypeName: Supertype` syntax, or alternatively `type TypeName: Supertype` for generics with default values.. + +Whenever a unique type is instantiated with a list of generics, these generics become part of the instantiated type and will not be discarded even if the generics aren't used in the supertype, acting sort of like metadata for the instantiated unique type. + +It's important to note that an instantiated generic unique type T of unique type A will only be able to be cast to another instatiated generic unique type U of unique type A if the generic values of T are all subtypes of the generic values of U (for instance, `A -> A<"hello">` is invalid as `string` is not a subtype of "hello", however `A<"hello"> -> A` is valid, and so is `A<"hello"> -> A`) + +An example of usage: + +```luau +type i53: number +type Entity: i53 + +local entA = 102 :: Entity +local entB = 122 :: Entity + +local function get(ent: Entity): T + +local value = get(entA) -- string +local value1 = get(entB) -- number +``` + ### Type function semantics Due to the nature of unique types, there would be no way to construct unique types in type functions. @@ -135,6 +162,8 @@ However, since you should be able to input unique types into type functions, or # Drawbacks --- +- Values need to be explicitly cast to the unique type before being able to be assigned to an annotated variable of a unique type + # Alternatives --- From f20549860700ec6a8e7a146d95eb660f59e2c4da Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:14:47 +0700 Subject: [PATCH 10/35] Added function call examples to behavior with literals --- docs/unique-types.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index b0969a6c8..fc97bf252 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -36,6 +36,11 @@ local user1: UserId = 2 -- Doesnt work, must cast first local user2 = 2 :: UserId -- Works! UserId is a subtype of number and its being typecast local user3 = "2" :: UserId -- Doesnt work, string is not a supertype of UserId local user2: UserId = 12323 :: PlaceId -- Doesnt work, could not convert PlaceId into UserId + +local function getPlaceData(id: PlaceId) + +local data = getPlaceData(1234) -- Doesnt work, must explicitly cast number to PlaceId +local data = getPlaceData(1234 :: PlaceId) -- Works! ``` ### Behavior with intersections From 691a9921c0f6797e4cfcbe7c94af4fa44d3747c7 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:18:19 +0700 Subject: [PATCH 11/35] Clarified no-intersection semantics --- docs/unique-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index fc97bf252..83f17d208 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -45,7 +45,7 @@ local data = getPlaceData(1234 :: PlaceId) -- Works! ### Behavior with intersections -Using a unique type in an intersection would result in `*error type*`, as a unique type simply denotes a distinct type that is a subtype of something else, and intersecting with that shouldn't be allowed. +Using a unique type in an intersection would result in `*error type*`, as a unique type simply denotes a distinct type that is a subtype of something else, and intersecting with that shouldn't be allowed because theres no actual "value" to intersect with here. ### Behavior with unions From 1266f5a20079867feeaf852c1d228b074f90ebcb Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:19:51 +0700 Subject: [PATCH 12/35] Added clarification to no-implicit-cast statement --- docs/unique-types.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 83f17d208..f41d3f546 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -25,7 +25,9 @@ The autocomplete of a unique type should inherit from its defined supertype, as ### Behavior with literals -When assigning a literal value to a variable, a cast will NOT be implicitly performed. A unique type cannot be cast to another unique type, however can be cast to types it is subtype of (defined by the type expression after the : in the unique types declaration) +When assigning a literal value to a variable, a cast will NOT be implicitly performed. For example, a number cannot be implicitly cast to a unique type that is a subtype of number, because a supertype cannot be implicitly cast into a subtype, and thus an explicit cast must be done first. + +A unique type cannot be cast to another unique type, however can be cast to types it is subtype of (defined by the type expression after the : in the unique types declaration) Illustrated in code: ```luau From 1a3d99c99d0106251c45c276ba616ec706be3804 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:21:26 +0700 Subject: [PATCH 13/35] Added better examples for no-inter-unique-type-casts --- docs/unique-types.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index f41d3f546..23418ad39 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -37,7 +37,9 @@ type PlaceId: number local user1: UserId = 2 -- Doesnt work, must cast first local user2 = 2 :: UserId -- Works! UserId is a subtype of number and its being typecast local user3 = "2" :: UserId -- Doesnt work, string is not a supertype of UserId -local user2: UserId = 12323 :: PlaceId -- Doesnt work, could not convert PlaceId into UserId +local user4: UserId = 12323 :: PlaceId -- Doesnt work, could not convert PlaceId into UserId +local user5 = 1234 :: PlaceId +local user6 = user5 :: UserId -- Doesnt work, could not convert PlaceId into UserId local function getPlaceData(id: PlaceId) From 6fc728af65404158a58d2f663c9391f3ede6b225 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:47:18 +0700 Subject: [PATCH 14/35] Clarified casting semantics --- docs/unique-types.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 23418ad39..158a7bec2 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -23,9 +23,9 @@ The proposed syntax to create a unique type is to define it using `type TypeName The autocomplete of a unique type should inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. -### Behavior with literals +### Variable definition semantics -When assigning a literal value to a variable, a cast will NOT be implicitly performed. For example, a number cannot be implicitly cast to a unique type that is a subtype of number, because a supertype cannot be implicitly cast into a subtype, and thus an explicit cast must be done first. +When assigning a value to a variable, a cast will NOT be implicitly performed. An explicit cast must be done first, because unique types are different types from types such as literals and primitives. A unique type cannot be cast to another unique type, however can be cast to types it is subtype of (defined by the type expression after the : in the unique types declaration) Illustrated in code: @@ -45,8 +45,14 @@ local function getPlaceData(id: PlaceId) local data = getPlaceData(1234) -- Doesnt work, must explicitly cast number to PlaceId local data = getPlaceData(1234 :: PlaceId) -- Works! + +local a = 10 +local moredata = getPlaceData(a) -- Again, doesnt work +local moreadaatatata = getPlaceData(a :: PlaceId) -- Works! ``` +Some more examples involving more types of literals: + ### Behavior with intersections Using a unique type in an intersection would result in `*error type*`, as a unique type simply denotes a distinct type that is a subtype of something else, and intersecting with that shouldn't be allowed because theres no actual "value" to intersect with here. @@ -79,8 +85,6 @@ local vec3_2: Vec3 = vec2_1 -- Doesnt work, "z" is missing from the type local vec3_3: Vec2 = vec3_2 -- Doesnt work, Vec3 cannot be cast into Vec2 despite the fact that Vec2 is a valid subtype of Vec3 ``` -However, an annotation will NOT perform an implicit cast on the value. To assign a value to a variable of a unique type, you must typecast it first. This is so the programmer is required to pass explicit intent that they want this value to be of this unique type - ### Refinement behavior Unique types can be refined through type guards and pattern matching based on their supertype. From 3a17a8f435e4ceecd4cc7e5eaf0450def152ce31 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:50:26 +0700 Subject: [PATCH 15/35] Added examples where generics define the supertype --- docs/unique-types.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index 158a7bec2..0d3e4d23e 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -152,6 +152,19 @@ local value = get(entA) -- string local value1 = get(entB) -- number ``` +Generic arguments can also be used to define the supertype, for example: + +```luau +type UserId: ValueType + +local function getUserId(): UserId +local function saveUserId(id: UserId) + +local id: UserId = getUserId() +saveUserId(id) -- Does not work! number is not a subtype of string +saveUserId(id :: UserId) -- Does not work! number is not a subtype of string in the generics list +``` + ### Type function semantics Due to the nature of unique types, there would be no way to construct unique types in type functions. From d5ecde8f4cd58c2bd0259de8261984f8fb420611 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:53:39 +0700 Subject: [PATCH 16/35] Added tostring example to generic examples Clarify usage of UserId type with tostring conversion. --- docs/unique-types.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index 0d3e4d23e..a6dd3341b 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -163,6 +163,7 @@ local function saveUserId(id: UserId) local id: UserId = getUserId() saveUserId(id) -- Does not work! number is not a subtype of string saveUserId(id :: UserId) -- Does not work! number is not a subtype of string in the generics list +saveUserId(tostring(id) :: UserId) -- Works! The type signature for tostring is tostring(...any) -> string, and since we now have a string it's able to be converted into UserId. ``` ### Type function semantics From 2b2aca5c0bc92d8b7ee105af1e22a57695f02838 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:58:01 +0700 Subject: [PATCH 17/35] Made motivation clearer --- docs/unique-types.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index a6dd3341b..c7bc786f7 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -12,7 +12,8 @@ Since Luau uses structural typing, there is no way to make a primitive distinct. Current workarounds like tagging (string & { _tag: "PlayerId" }) are messy and confuse autocomplete. -Unique types solve this by being able to be created with a supertype, giving them additional type information aside from being completely unique. +Unique types solve this by being completely unique from any other type, therefor allowing programmers to bar casts between different unique types. +A supertype and a list of generics can be assigned to a unique type to alter its subtyping behavior, making it easier to inter-operate between unique types and normal Luau structural types. A unique type will be able to be cast to its supertype, but not to other unique types or types that are not its supertype. ## Design From 28d2daa4f10f18b025860490ca541fd94bdd3e3b Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:13:37 +0700 Subject: [PATCH 18/35] Added clarification to design summary and changed generics example --- docs/unique-types.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index c7bc786f7..4c4418cd2 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -20,6 +20,8 @@ A unique type will be able to be cast to its supertype, but not to other unique --- The proposed syntax to create a unique type is to define it using `type TypeName: Supertype`, the unique type `TypeName` will be defined as having a supertype `Supertype`, defined after the colon. A unique type with no supertype is not allowed as that type would never exist and is "uninhabited". +A unique type is allowed to have other unique types as its supertype + ### Behavior with autocomplete The autocomplete of a unique type should inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. @@ -66,9 +68,10 @@ Using a unique type in a union would work, illustrated in something like: type UserIdNumber: number type UserIdString: string -local function getData(id: UserIdNumber | UserIdString) end +local function getData(id: UserIdNumber | UserIdString) -- This makes sense, UserIdNumber | UserIdString reads as "UserIdNumber, a type that is a subtype of number, or UserIdString, a type that is a subtype of string". +local function getDataStringy(id: string | UserIdString) -- This also makes sense, string | UserIdString reads as "A string, or UserIdString, a type that is a subtype of string". -local data = getData(1234 :: UserIdNumber) -- This makes sense, UserIdNumber | UserIdString reads as "UserIdNumber, a type that is a subtype of number, or UserIdString, a type that is a subtype of string". +local data = getData(1234 :: UserIdNumber) ``` ### Casting semantics @@ -142,15 +145,17 @@ An example of usage: ```luau type i53: number -type Entity: i53 +type Component: i53 +type Entity: i53 -local entA = 102 :: Entity -local entB = 122 :: Entity +local function newEntity(): Entity +local function newComponent(): Component +local function get(entity: Entity, component: Component): T -local function get(ent: Entity): T +local entity = newEntity() +local component = newComponent<>() -local value = get(entA) -- string -local value1 = get(entB) -- number +local value = get(entity, component) -- value is string! ``` Generic arguments can also be used to define the supertype, for example: From aa4d207339ce1015fb34de19cc8d42c56f26d083 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:18:22 +0700 Subject: [PATCH 19/35] Fixed invalid casts in casting semantics example --- docs/unique-types.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 4c4418cd2..62d7a0e20 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -81,8 +81,8 @@ Unique types can be casted to other unique types, or other structural types prov type Vec2: { x: number, y: number } type Vec3: { x: number, y: number, z: number } -local vec2_1 = { x=1, y=1 } :: Vec2 -local vec3_1 = { x=1, y=1, z=2 } :: Vec3 +local vec2_1 = { x=1, y=1 } +local vec3_1 = { x=1, y=1, z=2 } local vec2_2: Vec2 = vec3_1 -- Works, "x" and "y" are present, which is all that's required local vec3_2: Vec3 = vec2_1 -- Doesnt work, "z" is missing from the type From 08c45a1209dded81678c921aa4f7cdc11b871944 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:36:50 +0700 Subject: [PATCH 20/35] Amended type function semantics --- docs/unique-types.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 62d7a0e20..d6324433e 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -178,7 +178,7 @@ Due to the nature of unique types, there would be no way to construct unique typ However, since you should be able to input unique types into type functions, or use it as an upvalue, the following are some semantic rules for unique `type` objects: -- Calling `type:__eq()` or using the `==` operator on a unique `type` object on any type other than itself should return `false`. +- Using `type:__eq()` on an instantiated unique `type` object on any type other than itself or the type it was instantiated from should return `false` (for example, where T is instantiated from UniqueType and passed as a parameter, `T == UniqueType` -> `true`, `T == T` -> `true`, `UniqueType == types.number` -> `false`, `T == types.string` -> `false`). - There should be a new valid string input to `type:is()`, which is `"unique"`. Illustrated in code: ```luau type function hi(T) @@ -191,6 +191,22 @@ However, since you should be able to input unique types into type functions, or end ``` - Unique types that are passed into type functions should have a `.tag` property set to `"unique"` too. +- Implement a new method to `type`, which is `type:generics() -> {type}`. This will return the list of instantiated generic values bound to the unique type. +- Implement a new method to `type`, which is `type:setgenerics({type}) -> ()`. This will set the list of instantiated generic values bound to the unique type. +- `type:is()` will work if you try to check the supertype of a unique type, so for example: + ```luau + type PlayerId: T + + type function playerIdToString(t: type) + assert(t:is("number") and t == PlayerId) + local newid = PlayerId(types.string) + return newid + end + + local a: PlayerId + local b: playerIdToString = tostring(a) + ``` +- `type:issubtypeof(T)` should return true if T is defined as a supertype of the unique type, false if otherwise. # Drawbacks --- From 9a215b5d145dcb90bd8f388a501ed591c8edf95e Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:24:40 +0700 Subject: [PATCH 21/35] Amended intersection behavior to work with refinements --- docs/unique-types.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index d6324433e..bc930c334 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -58,7 +58,15 @@ Some more examples involving more types of literals: ### Behavior with intersections -Using a unique type in an intersection would result in `*error type*`, as a unique type simply denotes a distinct type that is a subtype of something else, and intersecting with that shouldn't be allowed because theres no actual "value" to intersect with here. +Using a unique type in an intersection would simply intersect with the subtype of the unique type, for example: +```luau +type Thing: {a: string} +type ExtendedThing = Thing & {b: number} -- Aliases still work with unique types! +-- The subtype of ExtendedThing has been expanded, and since in the case of intersections, wider = subtype, that means ExtendedThing is now a subtype of {a: string} which is the supertype of Thing. + +local thing = {a: string, b: number} :: ExpandedThing -- Works! +local thing2 = thing :: Thing -- This works! Since thing is actually the same unique type, just with the expanded supertype of ExtendedThing, and ExtendedThing is a subtype of {a: string} which is the supertype of Thing, that means this cast is valid. +``` ### Behavior with unions @@ -133,6 +141,18 @@ local function handleUEvent(event: UEvent) end ``` +However, if a member of a unique type was to be refined like so: + +```luau +type Proxy: {inst: Instance?, metadata: {[string}: any}} + +local function modify(p: Proxy) + if p.inst then + -- The supertype of p would be refined to Proxy & {inst: ~(false?) & Instance, metadata: {[string]: any}}, narrowing the type and becoming a subtype of the original supertype. + end +end +``` + ### Generic arguments semantics To accomodate usage of generics, unique types are able to declare a list of generic arguments using the `type TypeName: Supertype` syntax, or alternatively `type TypeName: Supertype` for generics with default values.. From 68069d1fcae11f1f94bd4abe5605cd42a33feaa0 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:25:50 +0700 Subject: [PATCH 22/35] Amended incorrect usage of the word subtype --- docs/unique-types.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index bc930c334..b105c2e39 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -58,11 +58,11 @@ Some more examples involving more types of literals: ### Behavior with intersections -Using a unique type in an intersection would simply intersect with the subtype of the unique type, for example: +Using a unique type in an intersection would simply intersect with the supertype of the unique type, for example: ```luau type Thing: {a: string} type ExtendedThing = Thing & {b: number} -- Aliases still work with unique types! --- The subtype of ExtendedThing has been expanded, and since in the case of intersections, wider = subtype, that means ExtendedThing is now a subtype of {a: string} which is the supertype of Thing. +-- The supertype of ExtendedThing has been expanded, and since in the case of intersections, wider = subtype, that means ExtendedThing is now a subtype of {a: string} which is the supertype of Thing. local thing = {a: string, b: number} :: ExpandedThing -- Works! local thing2 = thing :: Thing -- This works! Since thing is actually the same unique type, just with the expanded supertype of ExtendedThing, and ExtendedThing is a subtype of {a: string} which is the supertype of Thing, that means this cast is valid. From 19730ed2f8763adf53ef68c25e807e69effcbdd6 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:30:06 +0700 Subject: [PATCH 23/35] Replaced a stray } with ] --- docs/unique-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index b105c2e39..d0e21972e 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -144,7 +144,7 @@ end However, if a member of a unique type was to be refined like so: ```luau -type Proxy: {inst: Instance?, metadata: {[string}: any}} +type Proxy: {inst: Instance?, metadata: {[string]: any}} local function modify(p: Proxy) if p.inst then From d42cb64bb5483c58fb1b56db9b58a00ab5f2c367 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:41:12 +0700 Subject: [PATCH 24/35] Added #123 nominal typing rfc as one of the alternatives to this rfc Add a section about the nominal typing RFC as an alternative. --- docs/unique-types.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index d0e21972e..c61f12426 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -236,6 +236,10 @@ However, since you should be able to input unique types into type functions, or # Alternatives --- +### The nominal typing rfc +There is an alternative nominal typing rfc that proposes encapsulating structural types inside of nominal types instead: +[#123](https://github.com/luau-lang/rfcs/pull/123) + ### Just use tables My example of distinct UserId and AssetId types could instead be written as From e0184babea8ec522d313f2e758cebe3bd770b104 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:44:02 +0700 Subject: [PATCH 25/35] Amended casting semantics section --- docs/unique-types.md | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index c61f12426..7e317ec48 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -26,7 +26,7 @@ A unique type is allowed to have other unique types as its supertype The autocomplete of a unique type should inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. -### Variable definition semantics +### Casting semantics When assigning a value to a variable, a cast will NOT be implicitly performed. An explicit cast must be done first, because unique types are different types from types such as literals and primitives. @@ -54,7 +54,19 @@ local moredata = getPlaceData(a) -- Again, doesnt work local moreadaatatata = getPlaceData(a :: PlaceId) -- Works! ``` -Some more examples involving more types of literals: +Unique types can be casted to other unique types, or other structural types provided the types are compatible in a structural manner. That is to say: + +```luau +type Vec2: { x: number, y: number } +type Vec3: { x: number, y: number, z: number } + +local vec2_1 = { x=1, y=1 } +local vec3_1 = { x=1, y=1, z=2 } + +local vec2_2: Vec2 = vec3_1 -- Works, "x" and "y" are present, which is all that's required +local vec3_2: Vec3 = vec2_1 -- Doesnt work, "z" is missing from the type +local vec3_3: Vec2 = vec3_2 -- Doesnt work, Vec3 cannot be cast into Vec2 despite the fact that Vec2 is a valid subtype of Vec3 +``` ### Behavior with intersections @@ -82,21 +94,6 @@ local function getDataStringy(id: string | UserIdString) -- This also makes sens local data = getData(1234 :: UserIdNumber) ``` -### Casting semantics -Unique types can be casted to other unique types, or other structural types provided the types are compatible in a structural manner. That is to say: - -```luau -type Vec2: { x: number, y: number } -type Vec3: { x: number, y: number, z: number } - -local vec2_1 = { x=1, y=1 } -local vec3_1 = { x=1, y=1, z=2 } - -local vec2_2: Vec2 = vec3_1 -- Works, "x" and "y" are present, which is all that's required -local vec3_2: Vec3 = vec2_1 -- Doesnt work, "z" is missing from the type -local vec3_3: Vec2 = vec3_2 -- Doesnt work, Vec3 cannot be cast into Vec2 despite the fact that Vec2 is a valid subtype of Vec3 -``` - ### Refinement behavior Unique types can be refined through type guards and pattern matching based on their supertype. From f42e1c9824f5efee3a7a8cbec156585fd4afc455 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:44:58 +0700 Subject: [PATCH 26/35] Fixed an incorrect statement regarding casts --- docs/unique-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 7e317ec48..3c82cfe3b 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -54,7 +54,7 @@ local moredata = getPlaceData(a) -- Again, doesnt work local moreadaatatata = getPlaceData(a :: PlaceId) -- Works! ``` -Unique types can be casted to other unique types, or other structural types provided the types are compatible in a structural manner. That is to say: +Unique types can be casted to other structural types provided the types are compatible in a structural manner. That is to say: ```luau type Vec2: { x: number, y: number } From 01118fca4e3acb39b606f710029a66e9380a280b Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:47:49 +0700 Subject: [PATCH 27/35] Amended casting semantics vector example being wrong --- docs/unique-types.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 3c82cfe3b..a7b7a23e4 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -54,7 +54,7 @@ local moredata = getPlaceData(a) -- Again, doesnt work local moreadaatatata = getPlaceData(a :: PlaceId) -- Works! ``` -Unique types can be casted to other structural types provided the types are compatible in a structural manner. That is to say: +Unique types can be casted to other structural types provided the types are compatible in a structural manner and vice versa. That is to say: ```luau type Vec2: { x: number, y: number } @@ -63,9 +63,11 @@ type Vec3: { x: number, y: number, z: number } local vec2_1 = { x=1, y=1 } local vec3_1 = { x=1, y=1, z=2 } -local vec2_2: Vec2 = vec3_1 -- Works, "x" and "y" are present, which is all that's required -local vec3_2: Vec3 = vec2_1 -- Doesnt work, "z" is missing from the type -local vec3_3: Vec2 = vec3_2 -- Doesnt work, Vec3 cannot be cast into Vec2 despite the fact that Vec2 is a valid subtype of Vec3 +local vec2_2 = vec3_1 :: Vec2 -- Works, "x" and "y" are present, which is all that's required +local vec3_2 = vec2_1 :: Vec3 -- Doesnt work, "z" is missing from the type +local vec3_3 = vec3_2 :: Vec2 -- Doesnt work, Vec3 cannot be cast into Vec2 despite the fact that Vec2 is a valid subtype of Vec3 + +local vec2_3 = vec2_2 :: {x: number, y: number} -- This works because {x: number, y: number} is a subtype of {x: number, y: number} (itself) ``` ### Behavior with intersections From 288bff87c340781572c7ef90d25266e73832f31c Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:57:46 +0700 Subject: [PATCH 28/35] Unique types that are used in type functions instead of unique types that are passed into type functions --- docs/unique-types.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index a7b7a23e4..c49b0d7c1 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -197,7 +197,7 @@ Due to the nature of unique types, there would be no way to construct unique typ However, since you should be able to input unique types into type functions, or use it as an upvalue, the following are some semantic rules for unique `type` objects: -- Using `type:__eq()` on an instantiated unique `type` object on any type other than itself or the type it was instantiated from should return `false` (for example, where T is instantiated from UniqueType and passed as a parameter, `T == UniqueType` -> `true`, `T == T` -> `true`, `UniqueType == types.number` -> `false`, `T == types.string` -> `false`). +- Using `type:__eq()` on a unique `type` object on any type other than itself or the type it was instantiated from should return `false` (for example, where T is instantiated from UniqueType and passed as a parameter, `T == UniqueType` -> `true`, `T == T` -> `true`, `UniqueType == types.number` -> `false`, `T == types.string` -> `false`). - There should be a new valid string input to `type:is()`, which is `"unique"`. Illustrated in code: ```luau type function hi(T) @@ -209,7 +209,7 @@ However, since you should be able to input unique types into type functions, or return T end ``` -- Unique types that are passed into type functions should have a `.tag` property set to `"unique"` too. +- Unique types that are used in type functions should have a `.tag` property set to `"unique"` too. - Implement a new method to `type`, which is `type:generics() -> {type}`. This will return the list of instantiated generic values bound to the unique type. - Implement a new method to `type`, which is `type:setgenerics({type}) -> ()`. This will set the list of instantiated generic values bound to the unique type. - `type:is()` will work if you try to check the supertype of a unique type, so for example: From 89c39d1b6df07f84efd335d63d2a7d5e9c039b35 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:44:06 +0700 Subject: [PATCH 29/35] Amended motivation for alternative rfc --- docs/unique-types.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/unique-types.md b/docs/unique-types.md index c49b0d7c1..6d57d2309 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -239,6 +239,8 @@ However, since you should be able to input unique types into type functions, or There is an alternative nominal typing rfc that proposes encapsulating structural types inside of nominal types instead: [#123](https://github.com/luau-lang/rfcs/pull/123) +The way this alternative rfc implements nominal types means that you aren't able to do operations such as multiplication on a `type UserId: number` type that results in a `number` type instead of another `UserId` type (desired) by making nominal types completely opaque when it is encapsulating a primitive type, unlike what this rfc proposes (since it relies on subtyping, any operations such as number + number -> number will be passed onto a unique type that uses it as its supertype even though the types are different from it.) + ### Just use tables My example of distinct UserId and AssetId types could instead be written as From 04119decca6371759eb45dedd93fcb63b84377d9 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:56:42 +0700 Subject: [PATCH 30/35] Amended redundant summary information --- docs/unique-types.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 6d57d2309..86b0f9bed 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -2,9 +2,7 @@ --- ## Summary --- -This RFC proposes adding support for unique types to luau. - -Unique types are an extension to the type checker, and make no changes to runtime semantics. +This RFC proposes adding support for unique types to luau, which are a way to define nominal types that are subtypes of a supertype, and are able to hold instantiated generic types as metadata. ## Motivation --- From e5b363e17c5d995d0c1a7c459969d6eccec02259 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:35:47 +0700 Subject: [PATCH 31/35] Amended operations & interface section --- docs/unique-types.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 86b0f9bed..a853e81d2 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -20,9 +20,33 @@ The proposed syntax to create a unique type is to define it using `type TypeName A unique type is allowed to have other unique types as its supertype -### Behavior with autocomplete +### Operations & interface of the unique type -The autocomplete of a unique type should inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. +The operations & interface of a unique type inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. +For example: + +```luau +type Vector4: setmetatable<{x: number, y: number, z: number, w: number}, { + __add: (Vector4, Vector4) -> (Vector4) +}> + +local function Vector4(x: number?, y: number?, z: number?, w: number?): Vector4 + +local a = Vector4(1, 2, 3, 4) +local b = Vector4(2, 3, 4, 5) + +print(a + b) -- Works +``` + +Or an example with primitives: +```luau +type PlaceId: string + +local id1: PlaceId = "1212" +local id2: PlaceId = "32302309" + +local result = id1..id2 -- The type of result here would be string, because it takes the __concat operator overload raw from string, which is the supertype defined for it. This may be undesirable however +``` ### Casting semantics From b13ff3a4b9bc3eea8efab7bfd83bbdb0a71d5a14 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:37:17 +0700 Subject: [PATCH 32/35] Amended amended operations & interface of a unique type --- docs/unique-types.md | 56 ++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index a853e81d2..7a52e3842 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -20,34 +20,6 @@ The proposed syntax to create a unique type is to define it using `type TypeName A unique type is allowed to have other unique types as its supertype -### Operations & interface of the unique type - -The operations & interface of a unique type inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. -For example: - -```luau -type Vector4: setmetatable<{x: number, y: number, z: number, w: number}, { - __add: (Vector4, Vector4) -> (Vector4) -}> - -local function Vector4(x: number?, y: number?, z: number?, w: number?): Vector4 - -local a = Vector4(1, 2, 3, 4) -local b = Vector4(2, 3, 4, 5) - -print(a + b) -- Works -``` - -Or an example with primitives: -```luau -type PlaceId: string - -local id1: PlaceId = "1212" -local id2: PlaceId = "32302309" - -local result = id1..id2 -- The type of result here would be string, because it takes the __concat operator overload raw from string, which is the supertype defined for it. This may be undesirable however -``` - ### Casting semantics When assigning a value to a variable, a cast will NOT be implicitly performed. An explicit cast must be done first, because unique types are different types from types such as literals and primitives. @@ -92,6 +64,34 @@ local vec3_3 = vec3_2 :: Vec2 -- Doesnt work, Vec3 cannot be cast into Vec2 desp local vec2_3 = vec2_2 :: {x: number, y: number} -- This works because {x: number, y: number} is a subtype of {x: number, y: number} (itself) ``` +### Operations & interface of a unique type + +The operations & interface of a unique type inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. +For example: + +```luau +type Vector4: setmetatable<{x: number, y: number, z: number, w: number}, { + __add: (Vector4, Vector4) -> (Vector4) +}> + +local function Vector4(x: number?, y: number?, z: number?, w: number?): Vector4 + +local a = Vector4(1, 2, 3, 4) +local b = Vector4(2, 3, 4, 5) + +print(a + b) -- Works +``` + +Or an example with primitives: +```luau +type PlaceId: string + +local id1 = "1212" :: PlaceId +local id2 = "32302309" :: PlaceId + +local result = id1..id2 -- The type of result here would be string, because it takes the __concat operator overload raw from string, which is the supertype defined for it. This may be undesirable however +``` + ### Behavior with intersections Using a unique type in an intersection would simply intersect with the supertype of the unique type, for example: From a6d7bea6265f3e5fb6fc9f95f8bdd2fcb19fadc3 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:51:56 +0700 Subject: [PATCH 33/35] Amended operations & interface of a unique type section to be more specific & remove examples of confusing type signatures --- docs/unique-types.md | 70 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 7a52e3842..1da1365b7 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -22,7 +22,7 @@ A unique type is allowed to have other unique types as its supertype ### Casting semantics -When assigning a value to a variable, a cast will NOT be implicitly performed. An explicit cast must be done first, because unique types are different types from types such as literals and primitives. +When trying to convert into a unique type, a cast will NOT be implicitly performed. An explicit cast must be done first, because unique types are different types from types such as literals and primitives. A unique type cannot be cast to another unique type, however can be cast to types it is subtype of (defined by the type expression after the : in the unique types declaration) Illustrated in code: @@ -48,6 +48,20 @@ local moredata = getPlaceData(a) -- Again, doesnt work local moreadaatatata = getPlaceData(a :: PlaceId) -- Works! ``` +However, unique type can be implicitly cast out into another type as long as the type is a supertype of it. + +```luau +type UniqueType: number + +local u = 10 :: UniqueType + +local function needsNumber(a: number) + +needsNumber(u) -- This is fine! number is a supertype of UniqueType, so an implicit cast is allowed. + +local a: number = u -- This is also fine +``` + Unique types can be casted to other structural types provided the types are compatible in a structural manner and vice versa. That is to say: ```luau @@ -67,8 +81,12 @@ local vec2_3 = vec2_2 :: {x: number, y: number} -- This works because {x: number ### Operations & interface of a unique type The operations & interface of a unique type inherit from its defined supertype, as the unique type is gauranteed to have everything that the supertype has. -For example: +However, in the case of primitive, aliased or other unique type supertype definitions, all usages of the supertype in the unique type's type signature (including metamethods and operator overloads) should be replaced with the unique type. + +The reasoning for this is because primitive, aliased or other unique types can reference themselves in their own definition, so to avoid examples of, for example, adding 2 unique types together and getting a primitive, this replacement of types must be done. + +An example with a table literal as a supertype (not primitive, aliased or unique type): ```luau type Vector4: setmetatable<{x: number, y: number, z: number, w: number}, { __add: (Vector4, Vector4) -> (Vector4) @@ -79,19 +97,59 @@ local function Vector4(x: number?, y: number?, z: number?, w: number?): Vector4 local a = Vector4(1, 2, 3, 4) local b = Vector4(2, 3, 4, 5) -print(a + b) -- Works +print(a + b) -- Works, since we defined the supertype as a literal it's unecessary to replace anything inside it ``` -Or an example with primitives: +And in the case of primitive, aliased or other unique type supertype definitions, here for example, the function signature `string.sub(string, number?, number?)` would be replaced with `PlaceId.sub(PlaceId, number?, number?)` in thexe following ample: ```luau type PlaceId: string local id1 = "1212" :: PlaceId local id2 = "32302309" :: PlaceId -local result = id1..id2 -- The type of result here would be string, because it takes the __concat operator overload raw from string, which is the supertype defined for it. This may be undesirable however +local subbed = id1:sub(3, -1) -- This works because the string type in the 1st argument has been replaced with PlaceId +local result = id1..id2 -- The type of result here would be PlaceId because the function signture "__concat: (string, string) -> string" would be replaced with "__concat: (PlaceId, PlaceId) -> PlaceId", this also means you cant concat a PlaceId with any ol' string +``` + +More examples: +```luau +type RayDirection: vector -- This is a primitive supertype, so any mentions of vector in its type signature should be replaced with RayDirection. The vector type itself does not have any methods, however it does have metamethods as operator overloads so that's where the type signature will be replaced. + +local add = (vector.create(10, 2, 2) :: RayDirection) + (vector.create(2, 2, 2) :: RayDirection) -- Works +local dir = vector.create(1, 2, 3) :: RayDirection +local len = vector.magnitude(vector.cross(dir, vector.one)) -- This works because converting out of a unique type is allowed to be done implicitly. However the return type of vector.cross will still be a vector. This MAY be undesirable but highly unlikely so. ``` +```luau +type ReadMode: "hi" -- This would be the case of a primitive supertype, why? Because "hi" is a subtype of string, and string is a primitive. So any usage of the string type inside the "hi" literal type would be replaced with ReadMode +``` + +With aliases: + +```luau +type Object = setmetatable<{}, {__index: {new: () -> Object}}> + +type MyObject: Object -- This would replace all usages of Object inside the type signature with MyObject for the type signature of MyObject. So in this case that means the function signature of new() in __index is now new: () -> MyObject. +``` + +It's important to note that library functions would NOT be affected. It is expected for developers to implement their own libraries for manipulating unique types that are subtypes of primitives, for example: + +```luau +type ImageBuffer: buffer +type u8: number +type usize: number + +local ImageBuffer = {} + +function ImageBuffer.writeu8(buf: ImageBuffer, offset: usize, value: u8) + buffer.writeu8(buf, offset, value) +end + +return ImageBuffer +``` + +There may be more complex examples however this is left as an exercise for the reader. + ### Behavior with intersections Using a unique type in an intersection would simply intersect with the supertype of the unique type, for example: @@ -253,6 +311,8 @@ However, since you should be able to input unique types into type functions, or --- - Values need to be explicitly cast to the unique type before being able to be assigned to an annotated variable of a unique type +- The introduction of nominal types into the luau type system would increase the complexity of the type system. It adds another thing for beginners to the language to learn, and requires additional work within the type solver. +- Programmers may be confused when mixing structural types and nominal types, especially as they are likely already used to the exclusively-structural nature of luau. Many things that seem to be "correct" from a structural perspective would be disallowed by the type system. # Alternatives --- From 6c1d9c4a3d16a169fb14b84bb398da6630976d24 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:55:05 +0700 Subject: [PATCH 34/35] Made example of aliases being interchangeable clearer that its talking about type aliases --- docs/unique-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 1da1365b7..8d1f6fc83 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -6,7 +6,7 @@ This RFC proposes adding support for unique types to luau, which are a way to de ## Motivation --- -Since Luau uses structural typing, there is no way to make a primitive distinct. If there are two types PlayerId and AssetId and they are both strings, the type checker allows a PlayerId to be passed into a function expecting AssetId because they are both just string. +Since Luau uses structural typing, there is no way to make a primitive distinct. If there are two type aliases PlayerId and AssetId and they are both `string`s, the type checker allows a PlayerId to be passed into a function expecting AssetId because they are both just aliases to `string`. Current workarounds like tagging (string & { _tag: "PlayerId" }) are messy and confuse autocomplete. From d2cc0ceada69ffe5bd8cf47fa651c568d76d5b90 Mon Sep 17 00:00:00 2001 From: athar_adv <90320855+athar-adv@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:56:37 +0700 Subject: [PATCH 35/35] Fixed grammar mistakes --- docs/unique-types.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/unique-types.md b/docs/unique-types.md index 8d1f6fc83..7341d7041 100644 --- a/docs/unique-types.md +++ b/docs/unique-types.md @@ -100,7 +100,7 @@ local b = Vector4(2, 3, 4, 5) print(a + b) -- Works, since we defined the supertype as a literal it's unecessary to replace anything inside it ``` -And in the case of primitive, aliased or other unique type supertype definitions, here for example, the function signature `string.sub(string, number?, number?)` would be replaced with `PlaceId.sub(PlaceId, number?, number?)` in thexe following ample: +And in the case of primitive, aliased or other unique type supertype definitions, here for example, the function signature `string.sub(string, number?, number?)` would be replaced with `PlaceId.sub(PlaceId, number?, number?)` in the following examples: ```luau type PlaceId: string @@ -111,7 +111,6 @@ local subbed = id1:sub(3, -1) -- This works because the string type in the 1st a local result = id1..id2 -- The type of result here would be PlaceId because the function signture "__concat: (string, string) -> string" would be replaced with "__concat: (PlaceId, PlaceId) -> PlaceId", this also means you cant concat a PlaceId with any ol' string ``` -More examples: ```luau type RayDirection: vector -- This is a primitive supertype, so any mentions of vector in its type signature should be replaced with RayDirection. The vector type itself does not have any methods, however it does have metamethods as operator overloads so that's where the type signature will be replaced.