Type Safety:
Typing
Put them in types.js
When you create a new Composi project with create-composi-app
, in the src/js folder there will be a file called types.js
. This imports default types from @composi/core
that are already used in the default code in your project's src/js/app.js file. In fact, this is where you will define any new JSDoc types you need for the code you write.
JSDoc Types
There are three ways we implement JSDoc types: on function/method parameters, as custom types with the @typedef
tag and with the @type
to indicate something's type. JSDoc supports the following native JavaScript types: null
, undefined
, string
, number
, Array
, Object
, Date
, RegExp
, Symbol
, as well as all the DOM types, such as Document
, Node
, Element
, HTMLElement
, HTMLDivElement
, etc.
Example
To show how to type your code we are going to use a simple example of a todo list. This will have a state object and we will need to add and delete items from the state. We will be using a tagged union for messages as well, which we will use to define the actions that the program's update method uses.
State
Let's start with the state. For our todo list we need an object that holds an array of items. Because it is a list, we need to keep track of keys for the virtual DOM, so we need a new key propert in the state. We also need a place to store whatever the user types in a form input to enable addding new items. Based on this we could have a state object like this:
const state = {
newKey: 104,
inputValue: '',
items: [
{
key: 101,
value: 'Talk to Mom'
},
{
key: 102,
value: 'Eat snacks'
},
{
key: 103,
value: 'Take a nap'
}
]
}
Now let's define its types. To do so we create new types using the @typedef
tag and define their properties with the @prop
tag.
// Define an Item type:
/**
* @typedef {Object} Item
* @prop {number} key
* @prop {string} value
*/
// Define the State type:
/**
* @typedef {Object} State
* @prop {number} newKey
* @prop {string} inputValue
* @prop {Item[]} items
*/
First we define an Item
type. Then, when we define the State
type, we define items as an array of type Item
. This is how you break down complex object types into simpler ones. Now we can import both the state and item types to use. We would need the item type if we want to iterate over the state's items.
Runtime Messages
The Composi runtime uses taged unions to make using messages simpler and more intuitive. But because of that union, they are a little tricky to type. You provide the union
method a series of string for the names of the messages you need to handle. It returns an object with methods corresponding to the names. These methods return message objects of type:
{
type: 'someMessage',
data: undefined
}
The union object also has a match
method that gets two arguments, the send message and an object of methods to use if there's a match. For there to be a match, the object has to have a method that corresponds to the message type received. That means the names of the message union method that sends the message have to be identical to the object method you provide the match method with. I know, it sounds complicated, but it works out really intuitiely. Here's our messages and an actions method that deals with those messages for our hypothetical todo list:
// Create tagged union of messages:
const Msg = union('updateInputValue', 'addItem', 'deleteItem')
// Destructure the union methods:
const {updateInputValue, addItem, deleteItem} = Msg
// Now define actions to handle these messages:
function actions(state, msg, send) {
// Destructure the state:
const prevState = {...{state}}
// Test what message was recieved:
return Msg.match(msg, {
updateInputValue: value => {
prevState.inputValue = value
return prevState
},
addItem: () => {
if (prevState.inputValue) {
prevState.items.push({
key: prevState.newKey++,
value: prevState.inputValue
})
prevState.inputValue = ''
} else {
alert('Please provide a value before submitting.')
}
return prevState
},
deleteItem(key) {
prevState.items = prevState.items.filter(item => item.key !== key)
return prevState
}
})
}
If you hover the cursor over these values you'll see that the editor has now idea what types these are. We can resolve this issue by providing types for these. We'll start by define types for the actions object methods, then we'll define the message union. The reason, the message union's match method needs to know the type of the actions object. We'll be putting these in our projects src/js/types.js
file after the type we defined for our project's state.
// Define actions object:
/**
* @typedef {Object} ActionMethods
* @prop {(value: string) => State} updateInputValue
* @prop {() => State} addItem
* @prop {(key: number) => State} deleteItem
*/
// Define message union.
// Use the ActionMethod as the return type of the match method.
/**
* @typedef {Object} MessageUnion
* @prop {(msg: Message, object: ActionMethods) => State} match
* @prop {(value: string) => Message} updateInputValue
* @prop {() => Message} addItem
* @prop {(key: number) => Message} deleteItem
*/
Now we can import this MessageUnion
type to use on our tagged union:
// Import MessageUnion and apply it to the Msg object:
/** @type {import('../types').MessageUnion} */
const Msg = union('updateInputValue', 'addItem', 'deleteItem')
Doing this give our destructured functions the correct types. Next lets type the actions function:
// Import default types for state, message and send:
/**
* @typedef {import('../types').State} State
* @typedef {import('../types').Message} Message
* @typedef {import('../types').Send} Send
*/
// Apply imported types to parameters:
/**
* @param {State} state
* @param {Message} msg
* @param {Send} send
*/
function actions(state, msg, send) {
// Apply State type to prevState:
/** @type {State} */
const prevState = {...{state}}
// Apply message union type here:
/** @type {import('../types').MessageUnion} */
return Msg.match(msg, {
updateInputValue: value => {
prevState.value = value
return prevState
},
addItem: () => {
if (prevState.inputValue) {
prevState.items.push({
key: prevState.newKey++,
value: prevState.inputValue
})
prevState.inputValue = ''
} else {
alert('Please provide a value before submitting.')
}
return prevState
},
deleteItem(key) {
prevState.items = prevState.items.filter(item => item.key !== key)
return prevState
}
})
}
With the above type additions we can hover over any properties and see the corret types being shown. This lets you know that TypeScript is understanding the types that you have provided for your code. It also means that if someone tries to change the code to provide a wrong type, they will immediated get a warning from Visual Studo Code that the type is incorrect.
Learn More About Types
We just touched the surface of types with JSDoc. For more information you can consult the JSDoc documentation. I've also writen two articles about typing: JavaScript Type Linting and Type Safe JavaScript with JSDoc.
Download Examples
You can download a collection of working examples of proejcts using the Composi runtime that are fully typed as we described above. These are available at https://github.com/composi/examples. Examine the types.js
file in each project src/js
folder to see how custom types are defined. Then examine all the other JavaScript files to see how types are imported and used.