Skip to content
This repository has been archived by the owner on Dec 13, 2023. It is now read-only.

Add Type Checking API #230

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
81 changes: 81 additions & 0 deletions docs/advanced/type-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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.

## Without 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). Without a type checking function, `title` must be queried to check for characteristics of an element:
```lua
Header.validateProps = function()
local title = props.title

if title.component then
return true
end

return false, tostring(Header) .. " prop title is not an element"
end
```
This approach is fragile, since it relies on undocumented internals.
MisterUncloaked marked this conversation as resolved.
Show resolved Hide resolved

## Roact Object Type Validation

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, tostring(Header) .. " prop title is not an element"
MisterUncloaked marked this conversation as resolved.
Show resolved Hide resolved
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, tostring(Header) .. " 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
```

!!! info
Because strings (hosts) and functions are valid component types, `Roact.isComponent` is less safe than `Roact.typeOf`. If safety is paramount, consider only allowing component classes, and checking that the `typeOf` the prop is `Roact.Type.StatefulComponentClass`.
MisterUncloaked marked this conversation as resolved.
Show resolved Hide resolved
64 changes: 64 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,28 @@ end

---

### Roact.typeOf
<div class="api-addition">Added in 1.2.0</div>

```
Roact.typeOf(roactObject) -> Roact.Type
```

Returns the [Roact.Type](#roacttype) of the passed in Roact object
MisterUncloaked marked this conversation as resolved.
Show resolved Hide resolved

---

### Roact.isComponent
<div class="api-addition">Added in 1.2.0</div>

```
Roact.isComponent(value) -> bool
```

Returns true is the provided value can be used by [Roact.createElement](#roactcreateelement).

---

### Roact.createRef
```
Roact.createRef() -> Ref
Expand Down Expand Up @@ -356,6 +378,48 @@ See [the Portals guide](../advanced/portals) for a small tutorial and more detai

---

## Enumerations

### Roact.Type
<div class="api-addition">Added in 1.2.0</div>

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
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/Type.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -37,12 +40,17 @@ function TypeInternal.of(value)
return value[Type]
end

function TypeInternal.nameOf(type)
return TypeNames[type]
end

getmetatable(Type).__index = TypeInternal

getmetatable(Type).__tostring = function()
return "RoactType"
end

strict(TypeInternal, "Type")
strict(TypeNames, "TypeNames")

return Type
4 changes: 4 additions & 0 deletions src/Type.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions src/TypeMirror.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
--[[
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,
}

setmetatable(Mirror, {
__tostring = function()
return "TypeMirror"
end
})
MisterUncloaked marked this conversation as resolved.
Show resolved Hide resolved

strict(Mirror, "TypeMirror")

return Mirror
MisterUncloaked marked this conversation as resolved.
Show resolved Hide resolved
53 changes: 53 additions & 0 deletions src/TypeMirror.spec.lua
Original file line number Diff line number Diff line change
@@ -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 throw if the value is not a valid type", function()
expect(pcall(Mirror.typeOf, 1)).to.equal(false)
expect(pcall(Mirror.typeOf, true)).to.equal(false)
expect(pcall(Mirror.typeOf, "test")).to.equal(false)
expect(pcall(Mirror.typeOf, print)).to.equal(false)
expect(pcall(Mirror.typeOf, {})).to.equal(false)
expect(pcall(Mirror.typeOf, newproxy(true))).to.equal(false)
end)
MisterUncloaked marked this conversation as resolved.
Show resolved Hide resolved

it("should return the assigned type", function()
local test = {
[Type] = Type.Element
}

expect(Mirror.typeOf(test)).to.equal(Mirror.Type.Element)
end)
end)
end
5 changes: 5 additions & 0 deletions src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/init.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -26,6 +28,7 @@ return function()
Event = true,
Change = true,
Ref = true,
Type = true,
None = true,
UNSTABLE = true,
}
Expand Down
14 changes: 14 additions & 0 deletions src/isComponent.lua
Original file line number Diff line number Diff line change
@@ -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
Loading