IdealOS Thinking
One of my original IdealOS blog posts from 2017 showed up on the front page of Hackernews the other day (comments here). This got me thinking about IdealOS again. I haven’t worked on it in a couple of years, but as I read through the comments and links to articles by people with similar ideas, I came to a realization. I am still working on it. Maybe not directly, but I’m still exploring ideas that are needed to build IdealOS. So with that in mind let’s take a look at what I’ve been working on lately.
Tiny Apps
One of the keys to IdealOS actually being “ideal” is having apps which are tiny. They should have very little code. The less code there is the less space there is for bugs to hide. Tiny apps also tend to run faster and be easier to maintain. If we assume an always accessible database, then theoretically a lot of the complexity of writing an app can go away, at least if we have the right abstractions.
To that end I’ve been prototyping a React library that lets you define a data schema, then use it as the core data structure in your React app without having to manage state updates, and automatically persisting to local storage, resulting in an extremely compact app.
Runtime Types
The core idea I'm prototyping is that you make a schema of what you want to represent using prototype objects then clone those for your actual instances. This lets us do all of the type calculations at runtime. Schema objects contain all of the information for other code to read the entire schema, generate UIs based on those schemas, load and save to JSON, and do other useful things.
The schema is composed of a few core object types: atoms, lists, maps, and.. Actually, it would be easier if I just show you.
Here’s a simple example of a todo list item:
const TodoItem = makeMap({
title: makeString("untitled"),
completed: makeBoolean(false),
})
type TodoItemType = typeof TodoItem
const TodoList = makeList<TodoItemType>(TodoItem)
type TodolistType = typeof TodoList
Now lets create a list with two items in it.
const data:TodolistType = TodoList.clone()
data.push(TodoItem.cloneWith({
completed: false,
title: "make breakfast"
}))
data.push(TodoItem.cloneWith({
completed: true,
title: "buy milk"
}))
From this list we can pull out the data, manipulate it, loop over it, and all of the other things we normally do with data structures. All items have a built in toString()
method so we can easily print it as well.
console.log(data.toString())
List:type_40070(make breakfast,false),
type_40070(buy milk,true)
Now let’s create a simple React component to view the list of items, and add, edit, or delete them.
export function TodoListExample() {
const [selected, onSelect] = useState(()=>data.get(0))
const addItem = () => data.push(TodoItem.cloneWith({
completed: false,
title: ""}))
const deleteItem = () => data.deleteAt(data.indexOf(selected))
return <VBox>
<HBox>
<Button title={"Add"} onClick={addItem}/>
<Button title={"Delete"} onClick={deleteItem}/>
</HBox>
<ListView data={data}
selected={selected}
onSelect={onSelect}
/>
</VBox>
}
That’s it. That is all of the code that we need to make a simple todo list. Rendered it looks like this:
Events and change propagation all happen internal to the structure, so your app doesn’t need to manage updates when new items are added. It will just do the right thing.
ListView
The ListView
component knows about these smart objects, so it can render and select elements from the list right away. When something changes in the list, or a property deep down inside the list’s elements changes the ListView will be notified and redraw itself automatically.
ListView does not know, however, what our TodoItem
object actually is so cannot render the items the way we want. By default it will call item.toString()
which recursively calls toString()
on the child properties. That is the output we saw above.
The next step is to tell the ListView how to render TodoItems using a renderer. If we just want a different string representation we can give it a StringRenderer
like this:
const SimpleTodoItemRenderer:StringRenderer<TodoItemType>
= (item:TodoItemType) => {
return item.get('completed') + " " + item.get('title')
}
This works if we just want text output, but more likely we want to be able to edit the todo item from the view. In that case we create a ListItemView renderer with a checkbox for the completed
property and an EditableLabel
for the title.
const ComplexRenderer: ListItemRenderer<ItemType>
= (item: ItemType) => {
return <>
<BooleanCheckbox value={item.get('completed')}/>
<EditableLabel value={item.get('title')}
strikethrough={item.get('completed')}/>
</>
}
Again, these components are aware the smart objects. BooleanCheckbox
knows how to toggle the boolean item.completed
property. The EditableLabel
knows how to edit a text property. All of the event propagation is handled internally.
With the Right Datastructure the Code Just Falls Out
So where are we now? We have the ability to define a datastructure out of lists, maps, and typed atoms like strings and numbers. Then we have smart react components know how to intelligently render the data structure and generally do the right thing. This is all pretty cool but we could have done this with regular TypeScript objects. The magic is that we have the types available at runtime. We have a schema. Because of the schema other features fall out for free:
Serialize
Save to JSON with data.toJSON()
which recursively saves the data structure to a JSON object.
Deserialize
Restore from a JSON object with TodoList.fromJSON(json)
.
Property Defaults
Provide default values when adding new fields to a type.
const TodoItem = makeMap({
title:makeString('untitled'),
completed:makeBoolean(false),
cost:makeNumber(10)
})
When restoring from JSON, if the stored object doesn’t have a cost property, the system will automatically use the default value of that property. Calling cloneWith
on Map objects will also use the defaults if the property is omitted.
// completed and cost are set to defaults
const todo2 = TodoItem.cloneWith({title:"hello”})
History / Undo & Redo
We can monitor all changes to a datastructure by attaching a change handler to the top node. This means we can record every change in memory and undo those changes with fully generic code. I created an AHistory
object which does exactly that.
const history = new AHistory<TodoListType>(data)
// add item
data.push(TodoItem.clone())
// undo the add
history.undo()
Constraints
The runtime schema also gives us constraints that can’t be expressed directly in the type system. Suppose we are making a drawing program and want to express that the radius of a circle must be a positive numeric value. We can do that with a constraint function that must return true for the change to be allowed.
const GreaterThanZero:Constraint<number> = (v) => v>0
const Circle = makeMap({
x:makeNumber(0),
y:makeNumber(0),
radius:makeNumber(1,{
cons: [GreaterThanZero]
}),
})
Now the Circle
type will never allow the radius to be set to something less than or equal to zero. UI components can use this information as well. A smart number editor could use a numeric input field with min set to 1 to disallow entering an invalid value.
The Missing Piece
One thing I haven’t talked about is: where does the data come from? For something document oriented like a drawing program, we can imagine the document is represented as a tree structure which is persisted to a JSON file or exported as an image.
One of the core features of IdealOS was that it was database oriented. Sure, we could store a document as a single object in the database, but part of the power of a db is the ability to search and link. Right now the TodoList
object wraps an array in program memory, but it doesn’t have to. What if, instead, TodoList wrapped a live query in the database. Then the program wouldn’t have to consider persistence at all. Data comes transparently from the system database into the UI. Editing a field would automatically update the database. Most applications would not need code for saving to disk or interacting with the database. The data structures would keep themselves up to date, letting the app focus on doing one thing well: helping the human interact with data.
Conclusion
The core idea of a schema, or runtime type information, is very powerful. We can build reusable components that automate away much of the busy work involved in making a GUI. The GUI can customize itself to the structure of the data rather than vice versa. Combined with a built in database, the applications for IdealOS can be tiny and incredibly easy to build. We can even imagine letting users build their own apps visually for IdealOS, resulting in an incredibly flexible and hackable system.
You can find the source to this unnamed db object library https://github.com/joshmarinacci/tool-toolkit.
Posted June 25th, 2024
Tagged: idealos