Draft: Designing a Data Flow Language - Type System
In sequence to my previous post where I talk about the pipe expressions, here I will explore some ideas for a type system.
My goal designing this type system is forcing synergy between it and the pipe expressions. I have already came to conclusion that I will use a dynamic an weak typing, but I also have in mind some possibilities for the following key points:
- Error handling
- Null values
- Functions and lambda
- Tuples and pattern matching
- Custom data types
Dynamic and Weak Typing
As I have already discussed, this language will have a dynamic weak typing because I don’t have enough skills (and time) to implement a full-featured statically type system, which would involve generics and inferaces and polymorphism and etc. Moreover, this is a scripting language for prototyping and fast interaction, it should not be used for complex and risk applications.
The weak typing part is about Stream convertion and some ideas related to error handling.
Errr Handling
Before diving into my decisions for the new language, I would like to digress a bit and give some notes about error handling in different languages.
References
My first reference is the classic try-catch
block. This constructions is the most known error handling technique in programming, and it sucks! The try
part opens a new scoped block which forces us to declare a variable outside if we want to use it later. If you want to ignore the error, you still have to use the try-catch, otherwise you program will explode in a runtime error.
let file;
try {
file = fs.Open("file");
} catch (error) {
return error;
}
let data;
try {
data = JSON.parse(file);
} catch (error) {
return error;
}
return data;
Golang offers an interesting alternative: handling the error as part of the result. Any function that may “throw an error”, just return it in a tuple with the results, forcing the user to acknowledge (treating or ignoring) the error explicity.
file, err := os.Read('file')
if err != nil {
return nil, err
}
var data Data
err := json.Unmarshall(file, &data)
if err != nil {
return nil, err
}
return data, nil
The part that I don’t like is not what you see in the code, is what you don’t see. Errors can still happen in runtime (for example, nil pointer exceptions), and when they happen, your program panic and to avoid it, you gotta add the ugly defer recover
s to avoid problems in isolate parts.
Functional languages usually handle errors using Optional (monads, maybe, union, whatever) types, which also make error handling explicit. The side effect is that you can use this pattern for eliminating null
types entirely. I lack the knowledge to know if these languages also have the panic structure similar to go.
case File.read(file_path) do
{:ok, file} ->
case Jason.decode(file) do
{:ok, data} -> {:ok, data}
{:error, _} -> {:error, :json_decoding_failed}
end
{:error, _} -> {:error, :file_read_failed}
end
The Elixir’s snippet above also shows another cool feature that synergizes with this strategy: pattern matching. Languages that borrow ideas from functional world such as rust, have similar error handling pattern:
// file.read_to_string(&mut contents)?; <-- the ? operator returns the error
// immediately
let file_content = match fs::read_to_string(file_path) {
Ok(content) => content,
Err(err) => {
eprintln!("Error reading file: {}", err);
return;
}
};
let data: Data = match serde_json::from_str(&file_content) {
Ok(data) => data,
Err(err) => {
eprintln!("Error parsing JSON: {}", err);
return;
}
};
Finally, languages such as Zig seek to handle errors in a more traditional way with a simple try
expression.
file, err = try readFile();
if (err.message != null) {
log.error("Error: {}", err.message);
return;
}
// Attempt to parse JSON and handle errors
data = try parseJSON(file);
if (err.message != null) {
log.error("Error: {}", err.message);
return;
}
My previous language, SHT, introduces a pattern which I baptized of “wrapping system” (I also like the “Gift 🎁 System”). Which is a mixture of the patterns above:
file = os.Open('file')? # wrap into a Maybe type
return if file! # unwrap the err
return json.Parse(file) # it will have same effect as the above
The new part is the unwrap
effect: a!
would be equivalent to { err := a.Err(); a = a.Value; err }
. In other words, the unwrap expression forces the variable a
to assume its value (either the content or the error) and the expression returns the error, so it can be handled separetely.
I like the shortcut if file! as err { ... handling err here ... }
but I don’t like the variable side effect. I also don’t like functions returning different types, which can lead to serveral new errors.
My Decision
If this were a static language, I would force any function that throws an error to return a Maybe object. Since it is not, I won’t mix type returns with errors and I will just throw the error instead, forcing the interruption. So raise
keyword added.
I will still be using the Maybe type, but for catching the error. I will use the SHT gift pattern for this. Thus ?
and !
operators confirmed.
There is only one difference from SHT system, the !
operator will return (err, val)
instead of just the error. The tuple system will handle the rest (see the tuple section below).
I still unsure about the unwrap changing the variable, but I will keep this behavior to keep the language concise.
Null Values
Null values are traditionallly used to denote invalid, missing or unitialized values. They may originate from external sources such as JSONs or internal sources such as variable declarions and errors.
Internal sources its easy to handle, we can just use Maybe to represent the missing or invalid values, and initiatializations should enforce default values (which could also may be Maybe in some cases).
External sources can be used as input or output of our program. For example, I can read a json data with null or I can write a json data with null. Same thing for databases, csvs, http requests. The thing is, we can’t just remove null.
dict = json.Load(`
{
"a": null
}
`)
dic['a'] -- present or not?
-- maybe, err or constant?
-- return err if present?
-- throw err if not?
Presence is important because it may have different meaning than undefined (vide JavaScript rcosystem) and the output may require it. Thus it comes to how to represent null values.
We could represent null as a special constant NULL, but then, what’s the point of removing it?
We could represent null dorectly as an error. But then we haventhe same discussion as the error throws/returns.
Representing null as Maybe will be the most consistent way.
Functions and lambda
I don’t have much thoughts about common functions. High order is no brainer here. My focus is how to create lambdas.
Let’s get back to the pipes, the main feature of the language. I tried a LOT of different syntaxes over the past few days. The most interesting format I have found was:
<value>
| <func> <param1>, <param2>, ...
-- which is equivalent to
<func>(Stream(<value>), <param1>, <param2>, ...)
-- examples:
ips
| map ip: http.Ping(ip)
--
value := math.fibonacci()
| takeWhile x: x < 4000000
| filter x: x%2 == 0
| sum
| to Number
I’ve also tried different syntaxes for lambdas. =>
like javascript, where
, lambda :
, etc. The best one was just :
.
double := x: x*2
distance := (x, y): math.Sqrt((x - y)^2)
two := :2
Tuples and pattern matching
I like tuples, they are useful. But I messed up really bad in SHT implementation. This time my approach will be:
- returns always returns a tuple, explicitly or implicitly.
- assignments only works with tuples, explicitly or implicitly.
fn one() { return 1 }
fn two() { return (1, 2) }
fn three() { return (1, 2, 3) }
a0 := one() -- a0 = 1
a1 := two() -- a1 = 1
a2, b2 := two() -- a2 = 1, b2 = 2
a3, b3, c3 := three() -- a3 = 1, b3 = 2, c3 = 3
a, b := one() -- error!
Lambda function parameters ((a, b): a + b
) could be represented as tuples internally too. Maybe even function calls (f(1, 2)
)?. Pattern matching take advantage of this:
match (i%3, i%5) {
(0, 0): println('FizzBuzz')
(0, _): println('Fizz')
(_, 0): println('Buzz')
(_, _): println(i)
}
Notice that match
can be seem as a block of lambda functions! The same could be applied to pipes:
ips:
| map ip: ip.Split('.')
| match (127, _, _, _): println('Blocked')
Some languages use _1
to reference the matched element after, which may be useful.
Custom data types
Too be honest, this is my least favorite topic, I just want something simple enough. Specially if I’m not working with statically typed language. The only concerns is how to handle initialization without null:
data GraphNode {
Id = newId()
Weight = 10
Parent = Maybe()
fn Print(this) {
print("%s", this.Id)
parent := this.Parent
while parent.Ok() {
print("-> %s", parent.Id)
parent = parent.Parent
}
}
}
root := GraphNode { Id = 'root' }
child := GraphNode { Parent = root }
Done.
Notice:
- Attributes are always initialized.
- Attributes with self-reference are Maybe
- Methods are just syntactical sugar.
print(node)
would be equivalent. - Public and private could follow Go rules.
- Instantiation initialization may overload the default initialization, so the code in the attribute assignment inside the data structure won’t be executed.
- Assigning a value to a maybe attribute just fucked everything up.
Up to now, I can’t think on any approach to fix this, other than immutability or operator override (maybe override the assignment = operator).