diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b343718..af648228 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased Changes
* Improved the error message when an invalid changed hook name is used. ([#216](https://github.com/Roblox/roact/pull/216))
* Fixed a bug where fragments could not be used as children of an element or another fragment. ([#214](https://github.com/Roblox/roact/pull/214))
+* Added Roact.Type, Roact.typeOf, and Roact.isComponent for Roact object and component type checking. ([#230](https://github.com/Roblox/roact/pull/230))
## [1.1.0](https://github.com/Roblox/roact/releases/tag/v1.1.0) (June 3rd, 2019)
* Fixed an issue where updating a host element with children to an element with `nil` children caused the old children to not be unmounted. ([#210](https://github.com/Roblox/roact/pull/210))
diff --git a/docs/advanced/type-validation.md b/docs/advanced/type-validation.md
new file mode 100644
index 00000000..57856dcb
--- /dev/null
+++ b/docs/advanced/type-validation.md
@@ -0,0 +1,62 @@
+In certain situations, such as when building reusable and customizable components, props may be composed of Roact objects, such as an element or a component.
+
+To facilitate safer development for these kinds of situations, Roact provides the `Roact.typeOf` and `Roact.isComponent` functions to help validate these objects.
+
+## Roact Object Type Validation
+
+Suppose we want to write a `Header` component with a prop for the title child element:
+```lua
+local Header = Component:extend("Header")
+
+function Header:render()
+ local title = props.title
+
+ return Roact.createElement("Frame", {
+ -- Props for Frame...
+ }, {
+ Title = title
+ })
+end
+```
+
+Now suppose we want to validate that `title` is actually an element using [validateProps](../../api-reference/#validateprops). With `Roact.typeOf` we can be certain we have a Roact Element:
+```lua
+Header.validateProps = function()
+ local title = props.title
+
+ if Roact.typeOf(title) == Roact.Type.Element then
+ return true
+ end
+
+ return false, "prop title is not an element"
+end
+```
+
+## Component Type Validation
+
+In some cases, a component will be more preferable as a prop than an element. `Roact.isComponent` can be used to see if a value is a plausible component and thus can be passed to `Roact.createElement`.
+
+```lua
+local Header = Component:extend("Header")
+
+Header.validateProps = function()
+ local title = props.title
+
+ if Roact.isComponent(title) then
+ return true
+ end
+
+ return false, "prop title can not be an element"
+end
+
+function Header:render()
+ local title = props.title
+ return Roact.createElement("Frame", {
+ -- Props for Frame...
+ }, {
+ Title = Roact.isComponent(title) and Roact.createElement(title, {
+ -- Props for Title...
+ })
+ })
+end
+```
\ No newline at end of file
diff --git a/docs/api-reference.md b/docs/api-reference.md
index a51216ca..7465d380 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -167,6 +167,28 @@ end
---
+### Roact.typeOf
+
Added in 1.2.0
+
+```
+Roact.typeOf(roactObject) -> Roact.Type
+```
+
+Returns the [Roact.Type](#roacttype) of the passed in Roact object, or `nil` if the input is not a Roact object.
+
+---
+
+### Roact.isComponent
+Added in 1.2.0
+
+```
+Roact.isComponent(value) -> bool
+```
+
+Returns true is the provided value can be used by [Roact.createElement](#roactcreateelement).
+
+---
+
### Roact.createRef
```
Roact.createRef() -> Ref
@@ -356,6 +378,48 @@ See [the Portals guide](../advanced/portals) for a small tutorial and more detai
---
+## Enumerations
+
+### Roact.Type
+Added in 1.2.0
+
+An enumeration of the various types of objects in Roact, returned from calling `Roact.typeOf` on Roact objects.
+
+#### Roact.Type.Binding
+`Roact.typeOf` object returned from `Roact.createBinding`
+
+---
+
+#### Roact.Type.Element
+`Roact.typeOf` object returned from `Roact.createElement`
+
+---
+
+#### Roact.Type.HostChangeEvent
+`Roact.typeOf` object returned when indexing into `Roact.Change`
+
+---
+
+#### Roact.Type.HostEvent
+`Roact.typeOf` object returned when indexing into `Roact.Event`
+
+---
+
+#### Roact.Type.StatefulComponentClass
+`Roact.typeOf` object returned from `Roact.Component:extend`
+
+---
+
+#### Roact.Type.StatefulComponentInstance
+`Roact.typeOf` object of self inside of member methods of `Roact.Component`
+
+---
+
+#### Roact.Type.VirtualTree
+`Roact.typeOf` object returned by `Roact.mount`
+
+---
+
## Component API
### defaultProps
diff --git a/mkdocs.yml b/mkdocs.yml
index 94430efe..09b9d707 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -23,6 +23,7 @@ nav:
- Portals: advanced/portals.md
- Bindings and Refs: advanced/bindings-and-refs.md
- Context: advanced/context.md
+ - Type Validation: advanced/type-validation.md
- Performance Optimization:
- Overview: performance/overview.md
- Reduce Reconcilation: performance/reduce-reconciliation.md
diff --git a/src/Type.lua b/src/Type.lua
index 156ee0ea..d2ca945a 100644
--- a/src/Type.lua
+++ b/src/Type.lua
@@ -15,9 +15,12 @@ local strict = require(script.Parent.strict)
local Type = newproxy(true)
local TypeInternal = {}
+local TypeNames = {}
local function addType(name)
- TypeInternal[name] = Symbol.named("Roact" .. name)
+ local symbol = Symbol.named("Roact" .. name)
+ TypeNames[symbol] = name
+ TypeInternal[name] = symbol
end
addType("Binding")
@@ -37,6 +40,14 @@ function TypeInternal.of(value)
return value[Type]
end
+function TypeInternal.nameOf(type)
+ if typeof(type) ~= "userdata" then
+ return nil
+ end
+
+ return TypeNames[type]
+end
+
getmetatable(Type).__index = TypeInternal
getmetatable(Type).__tostring = function()
@@ -44,5 +55,6 @@ getmetatable(Type).__tostring = function()
end
strict(TypeInternal, "Type")
+strict(TypeNames, "TypeNames")
return Type
\ No newline at end of file
diff --git a/src/Type.spec.lua b/src/Type.spec.lua
index f2477093..f5336767 100644
--- a/src/Type.spec.lua
+++ b/src/Type.spec.lua
@@ -20,5 +20,9 @@ return function()
expect(Type.of(test)).to.equal(Type.Element)
end)
+
+ it("should return a type's name", function()
+ expect(Type.nameOf(Type.Element)).to.equal("Element")
+ end)
end)
end
\ No newline at end of file
diff --git a/src/TypeMirror.lua b/src/TypeMirror.lua
new file mode 100644
index 00000000..091118ed
--- /dev/null
+++ b/src/TypeMirror.lua
@@ -0,0 +1,53 @@
+--[[
+ Mirrors a subset of values from Type.lua for external use, allowing
+ type checking on Roact objects without exposing internal Type symbols
+
+ TypeMirror: {
+ Type: Roact.Type,
+ typeOf: function(value: table) -> Roact.Type | nil
+ }
+]]
+
+local Type = require(script.Parent.Type)
+local Symbol = require(script.Parent.Symbol)
+local strict = require(script.Parent.strict)
+
+local ALLOWED_TYPES = {
+ Type.Binding,
+ Type.Element,
+ Type.HostChangeEvent,
+ Type.HostEvent,
+ Type.StatefulComponentClass,
+ Type.StatefulComponentInstance,
+ Type.VirtualTree
+}
+
+local MirroredType = {}
+for _, type in ipairs(ALLOWED_TYPES) do
+ local name = Type.nameOf(type)
+ MirroredType[name] = Symbol.named("Roact" .. name)
+end
+
+setmetatable(MirroredType, {
+ __tostring = function()
+ return "RoactType"
+ end
+})
+
+strict(MirroredType, "Type")
+
+local Mirror = {
+ typeList = ALLOWED_TYPES,
+ Type = MirroredType,
+ typeOf = function(value)
+ local name = Type.nameOf(Type.of(value))
+ if not name then
+ return nil
+ end
+ return MirroredType[name]
+ end,
+}
+
+strict(Mirror, "TypeMirror")
+
+return Mirror
\ No newline at end of file
diff --git a/src/TypeMirror.spec.lua b/src/TypeMirror.spec.lua
new file mode 100644
index 00000000..46448777
--- /dev/null
+++ b/src/TypeMirror.spec.lua
@@ -0,0 +1,53 @@
+return function()
+ local Type = require(script.Parent.Type)
+ local Mirror = require(script.Parent.TypeMirror)
+
+ describe("Type", function()
+ it("should return a mirror of an internal type", function()
+ local name = Type.nameOf(Type.Element)
+ local mirroredType = Mirror.Type[name]
+ expect(mirroredType).to.equal(Mirror.Type.Element)
+ end)
+
+ it("should not return the actual internal type", function()
+ local name = Type.nameOf(Type.Element)
+ local mirroredType = Mirror.Type[name]
+ expect(mirroredType).to.never.equal(Type.Element)
+ end)
+
+ it("should include all allowed types", function()
+ for _, type in ipairs(Mirror.typeList) do
+ local name = Type.nameOf(type)
+ local mirroredType = Mirror.Type[name]
+ expect(mirroredType).to.be.ok()
+ end
+ end)
+
+ it("should not include any other types", function()
+ local name = Type.nameOf(Type.VirtualNode)
+ local success = pcall(function()
+ local _ = Mirror.Type[name]
+ end)
+ expect(success).to.equal(false)
+ end)
+ end)
+
+ describe("typeOf", function()
+ it("should return nil if the value is not a valid type", function()
+ expect(Mirror.typeOf(1)).to.equal(nil)
+ expect(Mirror.typeOf(true)).to.equal(nil)
+ expect(Mirror.typeOf"test").to.equal(nil)
+ expect(Mirror.typeOf(print)).to.equal(nil)
+ expect(Mirror.typeOf({})).to.equal(nil)
+ expect(Mirror.typeOf(newproxy(true))).to.equal(nil)
+ end)
+
+ it("should return the assigned type", function()
+ local test = {
+ [Type] = Type.Element
+ }
+
+ expect(Mirror.typeOf(test)).to.equal(Mirror.Type.Element)
+ end)
+ end)
+end
\ No newline at end of file
diff --git a/src/init.lua b/src/init.lua
index f002f975..35866824 100644
--- a/src/init.lua
+++ b/src/init.lua
@@ -8,6 +8,7 @@ local createReconcilerCompat = require(script.createReconcilerCompat)
local RobloxRenderer = require(script.RobloxRenderer)
local strict = require(script.strict)
local Binding = require(script.Binding)
+local TypeMirror = require(script.TypeMirror)
local robloxReconciler = createReconciler(RobloxRenderer)
local reconcilerCompat = createReconcilerCompat(robloxReconciler)
@@ -37,6 +38,10 @@ local Roact = strict {
teardown = reconcilerCompat.teardown,
reconcile = reconcilerCompat.reconcile,
+ isComponent = require(script.isComponent),
+ typeOf = TypeMirror.typeOf,
+ Type = TypeMirror.Type,
+
setGlobalConfig = GlobalConfig.set,
-- APIs that may change in the future without warning
diff --git a/src/init.spec.lua b/src/init.spec.lua
index 7fcf79c8..c291ac73 100644
--- a/src/init.spec.lua
+++ b/src/init.spec.lua
@@ -13,6 +13,8 @@ return function()
update = "function",
oneChild = "function",
setGlobalConfig = "function",
+ typeOf = "function",
+ isComponent = "function",
-- These functions are deprecated and throw warnings!
reify = "function",
@@ -26,6 +28,7 @@ return function()
Event = true,
Change = true,
Ref = true,
+ Type = true,
None = true,
UNSTABLE = true,
}
diff --git a/src/isComponent.lua b/src/isComponent.lua
new file mode 100644
index 00000000..e87dc94d
--- /dev/null
+++ b/src/isComponent.lua
@@ -0,0 +1,14 @@
+local Portal = require(script.Parent.Portal)
+local Type = require(script.Parent.Type)
+
+-- Returns true if the provided object can be used by Roact.createElement
+return function(value)
+ local valueType = type(value)
+
+ local isComponentClass = Type.of(value) == Type.StatefulComponentClass
+ local isValidFunctionComponentType = valueType == "function"
+ local isValidHostType = valueType == "string"
+ local isPortal = value == Portal
+
+ return isComponentClass or isValidFunctionComponentType or isValidHostType or isPortal
+end
\ No newline at end of file
diff --git a/src/isComponent.spec.lua b/src/isComponent.spec.lua
new file mode 100644
index 00000000..44a02201
--- /dev/null
+++ b/src/isComponent.spec.lua
@@ -0,0 +1,33 @@
+local Component = require(script.Parent.Component)
+local Portal = require(script.Parent.Portal)
+local createElement = require(script.Parent.createElement)
+local isComponent = require(script.Parent.isComponent)
+
+return function()
+ it("should return true for a stateful component class", function()
+ local MyStatefulComponent = Component:extend("MyStatefulComponent")
+ expect(isComponent(MyStatefulComponent)).to.equal(true)
+ end)
+
+ it("should return true for a function component", function()
+ local MyFunctionComponent = function(props)
+ return createElement("Frame", {})
+ end
+ expect(isComponent(MyFunctionComponent)).to.equal(true)
+ end)
+
+ -- There's no way to guarantee the return type for a function in Lua at the moment
+ itSKIP("should not return true for a function that returns an invalid type", function() end)
+
+ it("should return true for a string representing a host instance type", function()
+ local host = "Frame"
+ expect(isComponent(host)).to.equal(true)
+ end)
+
+ -- In the future, an exhaustive enum of all possible host instance types could enable this check
+ itSKIP("should not return true for an invalid host component name", function() end)
+
+ it("should return true for a portal", function()
+ expect(isComponent(Portal)).to.equal(true)
+ end)
+end
\ No newline at end of file