about summary refs log tree commit diff
path: root/users/Profpatsch/blog/notes/an-idealized-conflang.md
blob: 5c6b39f6e81b3f1d305ada93410d177dc13614eb (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
tags: netencode, json
date: 2022-03-31
certainty: likely
status: initial
title: An idealized Configuration Language

# An Idealized Configuration Language

JSON brought us one step closer to what an idealized configuration language is,
which I define as “data, stripped of all externalities of the system it is working in”.

Specifically, JSON is very close to what I consider the minimal properties to represent structured data.

## A short history, according to me

In the beginning, Lisp defined s-expressions as a stand-in for an actual syntax.
Then, people figured out that it’s also a way to represent structured data.
It has scalars, which can be nested into lists, recursively.

```
(this is (a (list) (of lists)))
```

This provides the first three rules of our idealized language:

1. A **scalar** is a primitive value that is domain-specific.
   We can assume a bunch of bytes here, or a text or an integer.
   
2. A **list** gives an ordering to `0..n` (or `1..n`) values
   
3. Both a scalar and a list are the *same kind* of “thing” (from here on called **value**),
   lists can be created from arbitrary values *recursively*
   (for example scalars, or lists of scalars and other lists)


Later, ASN.1 came and had the important insight that the same idealized data structure
can be represented in different fashions,
for example as a binary-efficient version and a human-readable format.

Then, XML “graced” the world for a decade or two, and the main lesson from it was
that you don’t want to mix markup languages and configuration languages,
and that you don’t want a committee to design these things.

---

In the meantime, Brendan Eich designed Javascript. Its prototype-based object system
arguably stripped down the rituals of existing OO-systems.
Douglas Crockford later extracted the object format (minus functions) into a syntax, and we got JSON.

```
{
  "foo": [
    { "nested": "attrs" },
    "some text"
  ],
  "bar": 42
}
```

JSON adds another fundamental idea into the mix:

4. **Records** are unordered collections of `name`/`value` pairs.
   A `name` is defined to be a unicode string, so a semantic descriptor of the nested `value`.

Unfortunately, the JSON syntax does not actually specify any semantics of records (`objects` in JSON lingo),
in particular it does not mention what the meaning is if a `name` appears twice in one record.

If records can have multiple entries with the same `name`, suddenly ordering becomes important!
But wait, remember earlier we defined *lists* to impose ordering on two values.
So in order to rectify that problem, we say that

5. A `name` can only appear in a record *once*, names must be unique.

This is the current state of the programming community at large,
where most “modern” configuration languages basically use a version of the JSON model
as their underlying data structure. (However not all of them use the same version.)

## Improving JSON’s data model

We are not yet at the final “idealized” configuration language, though.

Modern languages like Standard ML define their data types as a mixture of 

* *records* (“structs” in the C lingo)
* and *sums* (which you can think about as enums that can hold more `value`s inside them)

This allows to express the common pattern where some fields in a record are only meaningful
if another field—the so-called `tag`-field—is set to a specific value.

An easy example: if a request can fail with an error message or succeed with a result.

You could model that as 

```
{
  "was_error": true,
  "error_message": "there was an error"
}
```

or

```
{
  "was_error": false,
  "result": 42
}
```

in your JSON representation.

But in a ML-like language (like, for example, Rust), you would instead model it as

```
type RequestResult 
  = Error { error_message: String }
  | Success { result: i64 }
```

where the distinction in `Error` or `Success` makes it clear that `error_message` and `result`
only exist in one of these cases, not the other.

We *can* encode exactly that idea into JSON in multiple ways, but not a “blessed” way.

For example, another way to encode the above would be

```
{ 
  "Error": { 
    "error_message": "there was an error"
  }
}
```

and

```
{ 
  "Success": { 
    "result": 42
  }
}
```

Particularly notice the difference between the language representation, where the type is “closed”only `Success` or `Error` can happen—
and the data representation where the type is “open”, more cases could potentially exist.

This is an important differentiation from a type system:
Our idealized configuration language just gives more structure to a bag of data,
it does not restrict which value can be where.
Think of a value in an unityped language, like Python.


So far we have the notion of 

1. a scalar (a primitive)
2. a list (ordering on values)
3. a record (unordered collection of named values)

and in order to get the “open” `tag`ged enumeration values, we introduce

4. a `tag`, which gives a name to a value

We can then redefine `record` to mean “an unordered collection of `tag`ged values”,
which further reduces the amount of concepts needed.

And that’s it, this is the full idealized configuration language.


## Some examples of data modelling with tags

This is all well and good, but what does it look like in practice?

For these examples I will be using JSON with a new `< "tag": value >` syntax
to represent `tag`s.

From a compatibility standpoint, `tag`s (or sum types) have dual properties to record types.

With a record, when you have a producer that *adds* a field to it, the consumer will still be able to handle the record (provided the semantics of the existing fields is not changed by the new field).

With a tag, *removing* a tag from the producer will mean that the consumer will still be able to handle the tag. It might do one “dead” check on the removed `tag`, but can still handle the remaining ones just fine.

<!-- TODO: some illustration here -->
    
An example of how that is applied in practice is that in `protobuf3`, fields of a record are *always* optional fields.

We can model optional fields by wrapping them in `< "Some": value >` or `< "None": {} >` (where the actual value of the `None` is ignored or always an empty record).

So a protobuf with the fields `foo: int` and `bar: string` has to be parsed by the receiver als containing *four* possibilities:

№|foo|bar|
|--:|---|---|
|1|`<"None":{}>`|`<"None":{}>`|
|2|`<"Some":42>`|`<"None":{}>`|
|3|`<"None":{}>`|`<"Some":"x">`|
|4|`<"Some":42>`|`<"Some":"x">`|

Now, iff the receiver actually handles all four possibilities
(and doesn’t just crash if a field is not set, as customary in million-dollar-mistake languages),
it’s easy to see how removing a field from the producer is semantically equal to always setting it to `<"None":{}>`.
Since all receivers should be ready to receive `None` for every field, this provides a simple forward-compatibility scheme.

We can abstract this to any kind of tag value:
If you start with “more” tags, you give yourself space to remove them later without breaking compatibility, typically called “forward compatibility”.


## To empty list/record or not to

Something to think about is whether records and fields should be defined
to always contain at least one element.

As it stands, JSON has multiple ways of expressing the “empty value”:

* `null`
* `[]`
* `{}`
* `""`
* *leave out the field*

and two of those come from the possibility of having empty structured values.

## Representations of this language

This line of thought originally fell out of me designing [`netencode`](https://code.tvl.fyi/tree/users/Profpatsch/netencode/README.md)
as a small human-debuggable format for pipeline serialization.

In addition to the concepts mentioned here (especially tags),
it provides a better set of scalars than JSON (specifically arbitrary bytestrings),
but it cannot practically be written or modified by hand,
which might be a good thing depending on how you look at it.

---

The way that is compatible with the rest of the ecosystem is probably to use a subset of json
to represent our idealized language.

There is multiple ways of encoding tags in json, which each have their pros and cons.

The most common is probably the “tag field” variant, where the tag is pulled into the nested record:

```
{
  "_tag": "Success",
  "result": 42
}
```

Which has the advantage that people know how to deal with it and that it’s easy to “just add another field”,
plus it is backward-compatible when you had a record in the first place.

It has multiple disadvantages however:

* If your value wasn’t a record (e.g. an int) before, you have to put it in a record and assign an arbitrary name to its field
* People are not forced to “unwrap” the tag first, so they are going to forget to check it
* The magic “_tag” name cannot be used by any of the record’s fields


An in-between version of this with less downsides is to always push a json record onto the stack:

```
{
  "tag": "Success",
  "value": {
    "result": 42
  }
}
```

This makes it harder for people to miss checking the `tag`, but still possible of course.
It also makes it easily possible to inspect the contents of `value` without knowing the
exhaustive list of `tag`s, which can be useful in practice (though often not sound!).
It also gets rid of the “_tag” field name clash problem.

Disadvantages:

* Breaks the backwards-compatibility with an existing record-based approach if you want to introduce `tag`s
* Verbosity of representation
* hard to distinguish a record with the `tag` and `value` fields from a `tag`ed value (though you know the type layout of your data on a higher level, don’t you? ;) )


The final, “most pure” representation is the one I gave in the original introduction:

```
{
  "Success": {
    "result": 42
  }
}
```

Now you *have* to match on the `tag` name first, before you can actually access your data,
and it’s less verbose than the above representation.

Disavantages:

* You also have to *know* what `tag`s to expect, it’s harder to query cause you need to extract the keys and values from the dict and then take the first one.
* Doing a “tag backwards compat” check is harder,
  because you can’t just check whether `_tag` or `tag`/`value` are the keys in the dict.