Serulian Getting Started Guide Install Blog File an issue

Serulian Guide

Serulian is a new language and development toolkit, designed with a focus on safety, scalability and ease of interop for writing large-scale web and mobile applications that run in environments that are traditionally served by ECMAScript.

Running under NodeJS

Serulian (as of writing this guide) has not been fully tested under NodeJS and therefore compatibility is listed as experimental at this time.

Background

Web and mobile application development has grown tremendously in recent years, reaching the kind of scale that only previously was seen in traditional Desktop applications. With this scale came a number of challenges focused around a unique constraint: the development environment used to create and maintain these applications. Unlike the Desktop, applications running on the web (and in some cases, on mobile devices) must ultimately be written in some form of ECMAScript, a language never designed for such scale.

ECMAScript vs JavaScript

While technically "JavaScript" is a dialect of ECMAScript, the term JavaScript is the more commonly used name, so we'll be using it throughout the remainder of this guide, even if it is less correct.

Large-scale development issues with JS

Developing large scale applications in JavaScript is fraught with a number of complications (note that this list is far from exhaustive):

  • Lack of typing results in poorly defined interfaces
  • Working with asychronous code (even with the new async keyword) can be ugly
  • Error handling is virtually non-existant
  • Reusing code is difficult to do safely and correctly
  • Existing packaging solutions can be heavy weight and require a good deal of hand-tuning
  • JavaScript can be overly expressive (easy to break) and highly under expressive (hard to extend)
  • Performance concerns can appear in very large applications and static optimizations are nearly impossible (without significant restriction of language use, ala Closure Compiler)

Why not TypeScript, ClojureScript, etc?

Short answer: We don't like to compromise!

Longer answer: TypeScript, ClojureScript and many other languages have tried to "thread the needle", so to speak, in one direction or another, to maintain compatibility with either their original source language (Clojure => ClojureScript) or their subset language (TypeScript => JavaScript).

The main upsides of this approach are to provide developers coming from these source languages with an existing set of code that functions, as well as somewhat of a familiarity with the language. The primary downside, however, is a requirement to compromise to ensure either compatibility or familiarity. These compromises ultimately make the languages harder to use and reduce their utility (sometimes in significant ways).

Serulian is an attempt (hopefully a good one!) to avoid these compromises, and utitlize this freedom to provide a forward-looking development experience that scales.

But what about {insert language here}?

I mean, we can't write about every language out there! If you have a language you love, let us know! We'd be more than happy to take a look and learn (read: steal shamelessly if necessary) from it!

Why Serulian?

In short, we think a "clean slate" allows us to adopt (or, you know... steal...) the features and designs we like best from existing languages, innovate on top of them, and ultimately build a system that developers love to use when working in this environment.

Serulian has the following overall goals:

  • Greatly reduce friction of development and maintainence of large-scale web applications
  • Promote global reuse of code wherever possible
  • Require strong safety of the code that is written

It attempts to accomplish these goals by having, amongst other things:

Basics

This section introduces the basics of working in Serulian.

Basic syntax

Serulian syntactically is most closely related to Go and Python, with aspects and influence from JavaScript, C# and Java.

Semicolons

Like in Go and JavaScript, semicolons in Serulian are optional and highly discouraged from being used unless absolutely necessary from a parsing perspective.

Semicolon insertion

The Serulian parser uses a technique called "semicolon insertion", styled after the Go compiler. The lexer will insert synthetic semicolons right before newlines (and EOF), if the last non-whitespace character on the line matches a predefined list.

Strings

A string is Serulian can be defined using single quotes, double quotes or backticks (for template strings:

var someString string = 'hello world!' var someOtherString string = "hello world!" var someTemplateString string = `hello world!`

The preferred syntax is the single quoted form.

Numbers

A number is Serulian can be defined in a number of forms:

var n1 int = 42 var n2 float64 = 42f var n3 float64 = 42.0 var n4 int = 0x1F

Note the difference between 42, an integer and 42f, a float

Booleans

Serulian supports literal boolean values of true and false.

Comments

Comments in Serulian follow the standard C-style commenting rules:

// A single line comment /* A multiline comment here */

Multiline comments starting with two stars are generally used for documentation comments.

Modules and Packages

All Serulian source code is found in .seru files, each which forms a single module.

A group of modules/source files within the same directory define a package.

For example, given a set of files somepackage/sample.seru and somepackage/another.seru, we find the two modules sample and another under the package somepackage.

Module contents

Modules can contain four constructs at top-level: variables, functions, imports and type definitions.

Variables

A variable is a single, mutable value found at the module, type or statement level:

var firstVariable string = 'hello world!'

Variables defined in modules and types must have a defined type, while those at the statement level can infer their type from their value expression (if any).

The value expression on a variable is optional iff the type of the variable allows null:

var anyValueVar any var optionalVar string?

It is best practice to place variables at the top of modules before functions.

Functions

A function is a named block of code that can be executed, with optional parameters and an optional return type:

function otherFunction() int { return 42 } function doNothingFunction() {} function Run() int { doNothingFunction() return otherFunction() }

Functions defined in modules and types must have a defined return type (unless void), while those at the statement level can infer their return type from their implementation.

It is best practice to place functions after variables in a module.

Packaging and imports

Serulian has a powerful (but opinionated) package system built in to the language and toolkit. The primary goal of this package system is to make reuse of versioned code as simple as possible.

Modules

Each Serulian source file defines a single localized module, accessible from other modules and packages by its name. For example, a file named somefile.seru will define a module named somefile.

Packages

A set of source files located in the same directory, each comprising a single module, in total comprise a localized package. Packages are accessible from other modules by their directory name. Names imported from the package will be searched within each of the package's modules, with the first matching name returned.

It is a general recommendation to not import names from packages shared amongst multiple modules within the package, due to the ambiguity.

Local imports

Imports in Serulian are defined using the import and from keywords, similar to Python.

Named Imports via import

To import a local module or package, the import keyword can be used:

import localmodule // All names accessible as localmodule.something import localpackage // All exported names accessible as localpackage.something

The names localmodule and localpackage will be added to the current module, and all accessible names can be accessed via the dot operator:

import localmodule function DoSomething() { localmodule.someMember }

Aliased Imports via from

To import a specific member or sets of members from a module or package, the from keyword can be used:

from localmodule import foo, bar, baz from localpackage import something, somethingElse as theAliasForSomethingElse

Each name following the import keyword will be located within the specified module or package and imported directly into the current module with its own name.

If an as clause is specified, the specified alias name will be used to refer to the imported member in the current module, instead of its defined name:

from localmodule import foo as bar // Available as `bar`

Access safety and naming

Accessibility and safety in Serulian is accomplished by naming rules, similar to Go.

Any member within a module that starts with an Uppercase letter is exported and therefore can be accessed from any other module or package.

Any member with a name starting in a lowercase letter, on the other hand, can only be used from within the same package. Any attempts to import such a name from outside of the package will result in an error:

import localmodule import somepackage function DoSomething() { localmodule.someMember // Allowed somepackage.someMember // Will fail somepackage.SomeMember // Allowed }

Why naming instead of keywords?

For readability at the use site. When reading code, it is clear whether an internal or External API is being used, simply by its name.

Relative imports

All imports performed against local modules and packages are relative to the current source file.

Why relative imports?

Short answer: They make things simple when working under a variety of environments. Python's absolute imports are notorious for causing issues when forgetting from which "root" to run the application. Absolute imports also harm code reusability, as they make it difficult to move code and rely on submodules outside of the central application.

Importing from a parent package

There are times when a package might need to import from a parent package. While this is more fragile approach (it sets a possibility for a bidirectional dependency), it can be done via the .. operator:

import "../someparentpackage"

Note the requirement of using quotes around the name. Any non-simple indentifier (not [A-Za-z0-9]+) import will require such quotes.

Remote packages

While local packages are an important part of working within a large code base, it is the ability to use remote packages that allows for significant code reuse.

Using a remote package

Remote packages in Serulian are imported using the same from syntax as local packages, but with the local path replaced with a path to a package found in source control.

Remote package support

Serulian currently supports importing packages from any URL implementing the go get discovery mechanism and using git. Support for additional VCS systems (likely Mercurial) can be added by implementing a structure and will likely happen in the near future.

To use a remote package, simply specify the package's discovery URL in the from line:

from "github.com/serulian/debuglib" import Log

In the above example, the package being imported is github.com/serulian/debuglib and, since both the branch and version are unspecified, the Serulian compiler will assume that the HEAD commit of the default branch should be used.

Non-versioned package checkout

If a non-version remote package is specified in Serulian, then the (specified or default) branch will be checked out on every single build. This is done to ensure that the build is always using the latest copy of the branch, forcing the user to fix any issues they encounter. If this is not the desired behavior (it rarely is for production code), then the import should be frozen at a particular version or commit.

Using a particular branch of a remote package

If a particular branch of a remote repository is required, it can be specified following the package name in the import using a colon:

from "github.com/serulian/debuglib:master" import Log

The above import will cause the package to be imported from the master branch. The package will be retrieved and updated on every build.

Using a specific tag/version of a remote package

To specify a particular tag/version of the remote package, place the tag or version to be used after an at-sign @:

from "github.com/serulian/debuglib@v1.0.0" import Log

The above import will cause the package to be imported from the v1.0.0 tag. The package will be cached and only pulled if not present in the cache.

Using a specific commit of a remote package

To specify a particular commit, place its short SHA in the import following the colon:

from "github.com/serulian/debuglib:ce6e94" import Log

The above import will cause the package to be imported from the ce6e94 commit. The package will be cached and only pulled if not present in the cache.

What kind of remote package import should I use?

The kind of import to use (default branch, specific branch, tag or commit) depends on the stability of the package being imported. The following table provides the general rules around remote imports:
Remote Package StatusFrequency of updateImport to Use
PublicFrequently or InfrequentlyTag/Version
PrivateInfrequentlyTag/Version or Commit
PrivateFrequentlyBranch or default

The general rule is: If the downstream dependency is volatile, freeze at a particular version or commit, unless the volatility is necessary.

Why not NPM?

The decision to handle remote packages in Serulian in the above manner is an attempt to address significant downsides in the use of NPM and other package management tools in other languages. In particular, NPM has a number of problems associated with it:
  • A single, centralized repository for all packages. While this helps with discovery, it fails when NPM is otherwise unavailable.
  • Reliance on a custom protocol and tooling means that you need an NPM-compatible registry to handle private packages vs simply using our existing VCS tools (git)
  • A single, global namespace. When a package is removed or renamed, all existing links to the package can break. There have also been instances where others have "replaced" the package with potentially malicious code.
Serulian may support NPM or another centralized package management system in the future, if we find that the benefits of discovery outweigh the negatives listed above.

Why the requirement for versioning?

It is always better to be explicit when working with a downstream dependency than not. In Go, for example, a number of tools have appeared to add versioning on top of the language's existing discovery mechanism, as discovery can otherwise be a fragile tool. Serulian enforces this versioning by treating non-specification as its name implies: The dependency is always up to date. While this can be annoying, it is done to ensure that if a piece of code needs a specific version of a dependency, it is explicit about it.

Managing remote packages

The Serulian remote package system is quite powerful, but can also be quite frustrating to update: Changes to packages can span multiple source files in a project, and changing all of these locations by hand can be a challenge.

To solve this problem, the Serulian toolkit provides a number of commands to manage imports on a global scale: freeze, unfreeze, update and upgrade.

Does your imported library use semantic versioning?

Semantic Versioning is a specification for defining how versions of software move in response to changes. If you are importing a package that defines semantic versions, it is highly recommended to use the update and upgrade commands, rather than freeze and unfreeze.

Import commands

Import commands in the Serulian toolkit are all performed by executing the Serulian toolkit binary with the imports command prefix:

serulian imports {subcommand} {sourcefiledir} {import-filter-1} {import-filter-2} {import-filter-n}
Specifying source files to be changed

The sourcefiledir argument specifies the lookup path for source files that should be modified:

sourcefiledirMeaning
.All .seru files in the current directory
somesubdirAll .seru files in the somesubdir sub-directory
./...All .seru files in the current directory and below, recursively.
somesubdir/...All .seru files in the somesubdir sub-directory and below, recursively.

To apply the imports command to all subdirectories recursively, the special syntax ... can be used, such as ./... or somesubdir/....

Specifying packages to be modified

The import-filter arguments specifies the packages to be modified. Multiple arguments can be specified to apply to multiple packages.

If a wildcard is needed, it can be used to match all packages under a specific path: github.com/somenamespace/*.

Freezing imports

Freezing an import in Serulian causes the import to be rewritten to include the HEAD commit SHA of the branch specified in the import.

Freezing is most useful when working with a downstream internal package or library that is constantly changing and does not have defined releases.

For example, given:

from "github.com/somenamespace/somepackage:somebranch"
executing the following command:
serulian imports freeze . github.com/somenamespace/somepackage
will result in (with abcdef12 representing the real short-SHA):
from "github.com/somenamespace/somepackage:abcdef12"

Unfreezing imports

Unfreezing an import in Serulian causes the import to be rewritten to remove the HEAD commit SHA of the branch specified in the import.

Unfreezing is typically used when working on multiple packages at once, allowing for the dependencies to be unversioned.

Unfreezing is also typically used with the VCS development flag on the Serulian compiler.

For example, given:

from "github.com/somenamespace/somepackage:abcdef12"
executing the following command:
serulian imports unfreeze . github.com/somenamespace/somepackage
will result in:
from "github.com/somenamespace/somepackage"

Updating imports

Note that this command only work for packages that define Semantic Versions.

Updating an import replaces the specified version with the latest minor version of the specified major version, if one is available.

For example, given:

from "github.com/somenamespace/somepackage@v1.2.3"
and a latest 1.*.* version of v1.3.0, executing the following command:
serulian imports update . github.com/somenamespace/somepackage
will result in:
from "github.com/somenamespace/somepackage@v1.3.0"

Note that only the minor and patch components of the version are changed. No major version will be changed to conform with semantic versioning. To change the major version, see the upgrade command.

When should I update?

Whenever possible! If the downstream package uses semantic versioning correctly, then calling serulian imports update should (in theory) cause no breakages in your code.

If a breakage does occur, your code is either relying on a private API, or the package maintainer has made a backwards-incompatible change, which is outside of the semantic versioning spec.

Upgrading imports

Note that this command only work for packages that define Semantic Versions.

Upgrading an import replaces the specified version with the latest major stable version of the pacakge, if one is available.

For example, given:

from "github.com/somenamespace/somepackage@v1.2.3"
and a latest stable version of v2.1.0, executing the following command:
serulian imports upgrade . github.com/somenamespace/somepackage
will result in:
from "github.com/somenamespace/somepackage@v2.1.0"

Note that only the latest stable version of the package will be used. This means that if there is a prelease version (specified with a +), it will be ignored.

When should I upgrade?

When you feel the benefits of the new version outweigh the development time necessary to fix any interaction with that library.

If the package being upgraded has a major version change, then chances are your code will no longer work perfectly with it.

A exhaustive test suite is typically recommended before performing upgrades.

Developing remote packages locally

Sometimes a project or application is broken into multiple packages, some which must be developed concurrently. While it is usually poor practice to develop multiple components together (this usually indicates a hard dependency which should be broken), it is still a common enough task that Serulian supports the use case.

Remote packages in Serulian can be made "local" by use of a special flag on the compiler called --vcs-dev-dir. When specified, any matching packages normally imported from VCS will instead use their local copies, allowing for easy concurrent development.

To use, simply add the --vcs-dev-dir flag to the build command:

serulian build myentrypointfile.seru --vcs-dev-dir=path/to/directory

Inside of that directory should be a folder structure matching the path of the package being developed. For example, for a package named github.com/somenamespace/mypackage, the folder structure should be:

development-dir
     -> github.com
     	-> somenamespace
     		-> mypackage
The path to the development-dir directory can then be used as an argument to --vcs-dev-dir to cause the compiler to use the local copy of github.com/somenamespace/mypackage instead of the remote copy.

VCS development rules

Please note that all packages being developed under the vcs-dev-dir flag must be unfrozen.

Working with JavaScript

Working with external JavaScript and other native code is a key aspect of developing large-scale web (and mobile) applications today. After all, it cannot be expected that all code being used is developed in a single language, especially a newer language like Serulian.

To make this use case as frictionless as possible, Serulian has built-in support for working with native code via the use of WebIDL.

WebIDL

WebIDL is a specification and format for defining the interface of APIs exposed within a web environment (usually the browser).

The W3C and most browsers use WebIDL to specify the APIs that the execution environment is exposing to the code running within (usually JavaScript). As an example, the WebSocket APIs exposed by Firefox can be found defind in the WebSocket.webidl file.

Seeing the need to work with external code and recognizing the benefits of working with existing standards, Serulian has chosen to adopt support for WebIDL formally as a way of enabling seamless interaction between Serulian code and external code.

A note about WebIDL compatibility

As of the time this document was last updated, Serulian's support for WebIDL is incomplete. While Serulian supports a good deal of the spec (interfaces, constructors, annotations, etc), there will be some level of incompatibility. The usual workaround is simply to remove the "extra" information. Development of the WebIDL support layer is continuing.

Using WebIDL in Serulian

Using WebIDL is as simple as importing it:

from webidl`WebSocket` import WebSocket

In the above example, the notation of webidl before the import tells Serulian to treat the imported source file (in this case WebSocket.webidl) as a WebIDL file, and load it into the type system accordingly.

Once an interface or function from WebIDL has been imported, it can be used like any other Serulian type (with few restrictions):

from webidl`WebSocket` import WebSocket function CreateWebSocket() { myWebSocket := WebSocket.new('ws://some/websocket/url') myWebSocket.send('hello websocket!') myWebSocket.close() }

In the above example, the full interface of the WebSocket is available and strongly type checked, ensuring the benefits of working within a strongly typed system while still being able to easily call an external API.

What about libraries that don't have a WebIDL?

Simple! Write your own! For example, this very playground makes use of the ACE Code Editor. To make this integration as easy as possible, a very simple ace.webidl specification was written.

What about TypeScript typings?

TypeScript typings is a toolset for installing TypeScript definition files (basically: interfaces without implementations), which provides a similar "define the API" layer to WebIDL. Unlike WebIDL, however, this approach, while popular, is not standard, and so WebIDL was chosen instead. Support for typings in Serulian is certainly doable, however, and if we get enough interest we'll make it a priority!

Dynamic access of JavaScript objects

If a WebIDL interface does not exist for working with a JavaScript object, or writing an interface is too heavy, the Dynamic access operator can be used to access members under a native object:

function SomeFunction(nativeObj any) { someProperty := nativeObj->someProperty }

The name someProperty will be looked up and returned. If the name does not exist on the object, null is returned.

Types

Why are types so important?

Serulian has a very powerful type system to ensure correctness and scalability of large applications. When developing large applications, the interfaces between different modules and packages (both external and internal) can change frequently, especially when there are many contributors. A good type system ensures that mismatches between caller and callee are found quickly and accurately, while not overly inhibiting the ability of developers to code quickly.

Basic Syntax

Types in Serulian are denoted by placing them after the member. For example, a variable with a defined type will have syntax:

var someVariable string = 'hello world!'

where string is the type of the variable, while a function returning a value might be defined as:

function someFunction() string { return 'hello world!' }

where string is the return type of the function.

Any

Serulian defines the special type/keyword any to refer to a value of, well, any type:

var someVariable any = 'hello world!' var someOtherVariable any = 42 var someThirdVariable any = null

Why do we need any?

Serulian defines the any type to represent the root of all types, as there are times when an unknown value must be accepted. It is similar to interface{} in Go, but with a shortened syntax.

Nullability

Nullability is the ability of the type system to distinguish between values that can be null and those that cannot. Unlike most languages, types in Serulian are non-null by default, which leads to safer code and stronger guarentees. Further, all access that may result in a null must be handled explicitly in Serulian, ensuring that null exceptions cannot occur in typed code.

Defining null types

Types in Serulian are non-null by default:

var cannotBeNull string = 'hello world'

To specify that a null value is allowed, the question mark ? is used:

var canBeNull string? = 'hello world'

Accessing under null types

Serulian treats all accesses of nulls strongly, requiring that the case be explicitly handled. For example, given the code:

var canBeNull string? = 'hello world'

Trying to access the Length property on the string directly will fail to compile, as the value may be null. Instead, the specialized nullable access operator ?. must be used, which ensures that if canBeNull is indeed null, null is returned instead of the property being accessed:

var canBeNull string? = 'hello world' function Run() int? { // Note the return type is int?, because ?. can return null return canBeNull?.Length }

Default values for null types

Sometimes the desired behavior for a nullable type is to simply replace the value if it is null. To make this easy, Serulian provides the nullable default operator ??, which ensures that if the value is null, the right hand side default value is used in its place:

var canBeNull string? = 'hello world' function Run() int { // Note the return value is now int, since the value cannot be null return canBeNull?.Length ?? 0 } function AnotherRun() int { return (canBeNull ?? '').Length // Another approach }

Asserting not null

There are cases where the developer of an application knows that the value received is never supposed to be null, but due to the strictness of the type system, the type is nullable anyway. To work within this situation, Serulian provides the non-null assertion operator !, which ensures that if the value is null, the program will panic and fail:

var canBeNull string? = 'hello world' function Run() int { return (canBeNull!).Length // We know it will never be null, so just assert it }

Null assertion operator usage

It is generally bad form to use the null assertion operator unless the downstream dependency or external value cannot be changed, as a null value will lead to application failure.

A valid use of the operator is when receiving objects from various JavaScript or DOM libraries: To be completely strict, the values returned by those libraries *may* be null, but in practice, we want our program to fail if they are. If you are using a null assertion operator against a Serulian library, consider changing that library to either return a non-null value or use the nullable default operator.

Kinds of Types

Serulian has five kinds of types that can be defined: Classes, Interfaces, Agents, Structs, or Nominal (Alias) Types.

Classes

A class in Serulian defines an implemented type, of which instances can be created. For example, a class might be used to represent a concrete collection, a defined component or an application as a whole.

Classes are defined with the class keyword and can contain zero or more variables, functions, constructors, properties, or operators.

Defining a basic class

A simple class is defined using the class keyword:

class SomeClass {}

By default, all classes have an implicit new constructor, which can be used to create an instance of the class from within the same package:

class SomeClass {} function Run() { SomeClass.new() // Create a new instance of the class. }

Why not new SomeClass()?

Most languages have the specialized syntax new SomeClass() because constructors are singular and specialized parts of the class.

In constrast, in Serulian, all constructors are simply specialized functions that help create instances of the class. Using the syntax SomeClass.new makes this clear, and also allows constructors to be aliased like all other functions:

var creator = SomeClass.new creator() // Returns a new instance of SomeClass

The "structural new" syntax can also be used to create an instance of the class from within the same package:

class SomeClass {} function Run() { SomeClass{} // Also creates a new instance of the class. }

If the class contains one (or more) non-nullable variables that do not have defined default values, the new constructor takes those values as arguments, defined in the order in which the variables appear:

class SomeClass { var requiredValue string var anotherRequired int } function Run() { SomeClass.new('requiredValueHere', 42) }

The "structural new" syntax can also be used to create the instance, with all required and any optional fields specified by name:

class SomeClass { var requiredValue string var anotherRequired int var hasDefault bool = false } function Run() { SomeClass{ requiredValue: 'hello world', anotherRequired: 42, hasDefault: true, } }

Putting this together, we can write a simple program that creates a new instance of as class:

from "github.com/serulian/debuglib" import Log class SomeClass { var requiredValue string var anotherRequired int var hasDefault bool = false } function Run() { var sc = SomeClass.new('hello world', 42) Log(sc) var sc2 = SomeClass{ requiredValue: 'hello world', anotherRequired: 42, } Log(sc2) }

Both values logged to the console should be equivalent.

Constructing classes

While the new constructor (and structural construction) allow for basic construction, they have two significant downsides: They can only be used from within the same package as the class and, they don't provide context about the new instance of the class being returned.

To provide a defined and context-sensitive way of creating classes (and other types), Serulian provides for named constructors, which have explicit meaning and explicit visibility.

Why is context important?

Context about newly constructed classes instances can be very important for writing descriptive code. For example, in C#, creating a new list is generally done via the code new List<string>(). While this code reads "create me a new list", we don't know anything about the state of the List being created without reading the documentation for the List class. In contrast, the equivalent call in Serulian is List<string>.Empty(), clearly indicating that we are getting an empty list.

To define a constructor, we use the keyword constructor, specifying a name (and optional parameters). All constructors must return an instance of the class being created:

class SomeClass { constructor CreateMe() { return SomeClass.new() } }

Once defined, the constructor can be used in other code to create the instance of the class:

class SomeClass { constructor CreateMe() { return SomeClass.new() } } function DoSomething() { SomeClass.CreateMe() // Returns a new instance of SomeClass. }

If a constructor's name starts with a capital letter, the constructor can be used from outside of the package as per the Serulian accessibility rules.

It is generally recommended to be as descriptive as possible when naming constructors, to ensure readable code:

class MyCollection { constructor Empty() { ... } // Good! Clearly creates an empty version of the collection. constructor CopyOf(other MyCollection) { ... } // Good! constructor Create() { ... } // Bad... what is the state of the collection? }

Accessing a class from within itself

A class can be accessed from within any of its instance members via the this keyword:

class SomeClass { var someString string = 'hello world' function GetString() string { return this.someString } }

Composing classes

Serulian does not support traditional OOP inheritance, instead choosing to promote code reuse via the concept of agents and implicitly defined interfaces. This helps classes avoid the fragile base class problem, as well as providing for a more "mixin" style solution.

What?! I need inheritance!

Inheritance, in many cases, provides a lot of value. However, the classic examples (e.g. Square -> Rect -> Shape) woefully underestimated the complexity of relationships between classes. In reality, most "inheritance" is in fact used as single-parent mixins for code sharing. Agents help provide this same ability, without more complicated typing rules and with the added benefit of higher safety.

Interfaces

Interfaces in Serulian are implicit definitions of the set or subset of functionality that another type provides.

Interfaces are defined using the interface keyword on a type:

interface SomeInterface {}

Interfaces can contain zero or more functions, properties, constructors, or operators.

Unlike classes, an interface cannot contain variables, as it cannot hold state.

Matching an interface

Interfaces are matched against other all types implicitly, which means no explicit notation is necessary to indicate that another type implements the interface. Instead, so long as the interface matches a portion of the members of the type exactly, that type is considered to implement the interface:

interface SomeInterface { function GetSomeInt int () property SomeProp string { get } } class SomeClass { function GetSomeInt() int { return 2 } function AnotherFunction() { } property SomeProp string { get { return 'hello world '} set { } } } class AnotherClass { function GetSomeInt() int? { return 2 } }
Therefore:
// Valid! SomeClass implements both members. var si SomeInterface = SomeClass.new() // Fails to compile! AnotherClass's GetSomeInt has a different return type. var si SomeInterface = AnotherClass.new()

Benefits of implicit interfaces

The most important benefit of implicit interface implementation (say that five times fast!) is one of code reuse: If you are importing an external library and want to match against its types, you can do so from within your own package without changing the downstream dependency. This allows for greater code reuse, without the requirement of needlessly wrapping imported types.

Defining normal members

Functions and properties defined in an interface will be instance members, matching the implemententations found in other types, and therefore not having their own implementation:

interface SomeInterface { function GetSomeInt() int // No implementation property SomeProp string { get } // No implementation }

The implementation used at runtime will be the implementation deriving from the concrete type that matches (typically a class)

Defining constructors

Constructors are one of the two special cases when defined in an interface.

Constructors are, by definition, static, which means they exist on the type itself, rather than the instance. Therefore, in order to ensure the strictness of typing rules, a constructor defined in an interface must have a implementation, which will be called if the interface type is used directly in place of a concrete type:

interface SomeCollection { constructor Empty() { // Implementation required return MyCollection.Empty() } }

Why is an implementation required?

Because of Generics. Imagine a function which takes in a generic type that implements an interface with a constructor:
interface SomeCollection { constructor Empty() // Let's say the implementation isn't required... } function CreateCollection<T : SomeCollection>() T { return T.Empty() }
All looks okay... until we call it with the interface itself:
CreateCollection<SomeCollection>()

Now T is SomeCollection and therefore we'll be calling SomeCollection.Empty... which has no implementation. Therefore, constructors in interfaces must have an implementation.

In practice, this also provides a way of defining the default and preferred implementation of a constructor on a interface, leading to the interesting ability for the interface to provide forward-compatible private implementations, without exposing the underlying implementation to the upstream users.

Defining operators

Operators are similar to constructors in that they (with one exception) are static. Therefore, they must also have a default implementation:

interface SomeCollection { operator Plus(left SomeCollection, right SomeCollection) { // Default implementation return MyCollection.Join(left, right) } }

Working with interfaces

from "github.com/serulian/debuglib" import Log interface MyInterface { property SomeInt int { get } } class SomeClass { property SomeInt int { get { return 42 } } } class AnotherClass { property SomeInt int { get { return 12 } } } function Run() { var sc = SomeClass.new() var ac = AnotherClass.new() var inter MyInterface = sc Log(inter.SomeInt) inter = ac Log(inter.SomeInt) }

Agents

An agent in Serulian is a class-like type that is composed into a class or another agent, thereby providing a type-safe and extensible system for code reuse.

Agents are similar to struct composition in Go, but with one key difference: unlike in Go, they maintain a typed back-reference to the class or agent instance that is composing them (the principal).

Agents are defined using the agent keyword on a type:

agent SomeAgent for principalType {}

Agents, like classes, can contain zero or more variables, functions, constructors, properties, or operators.

Definining the type of the principal

The principal of an agent is the instance of the class or parent agent in which this agent is being composed. In order to ensure type safety, the allowed type of the principal is declared on the agent immediately following the for keyword after the agent's name.

Accessing the principal

Within the members of the agent, the principal keyword can be used to access the instance of the parent class or agent that composed the agent:

agent MyAgent for SomeInterface { function DoSomething() { this // The agent itself principal // The principal composing this agent, with type `SomeInterface` } }

Composing an agent

An agent can be composed into a class (or another agent) via the with keyword:

agent MyAgent for SomeInterface { function DoSomething() { ... } } class SomeClass with MyAgent {}

By specifying that a class or agent is created with an agent, the following changes occur on the composing type:

  • A new read-only field named after the agent is added to the class and must be initialized in constructors.
  • All accessible members of the agent will be aliased into the composing type, except those already defined by the type or by a previous agent.

For example, if we were to have:

agent MyAgent for SomeInterface { function DoSomething() { ... } function AnotherFunction() { ... } } class SomeClass with MyAgent { function DoSomething() { ... } }

SomeClass will gain a AnotherFunction method which will automatically be called on the agent. DoSomething, on the other hand, will not be aliased, as SomeClass has explicitly chosen to define that name.

Composing multiple agents

Multiple agents can be composed using the + operator:

class SomeClass with MyAgent + AnotherAgent + ThirdAgent {}

Precedence is always read left to right, with the composing type taking absolute precedence, followed by the agent on the left, and so on.

Renaming an agent

When an agent is composed into another type, the composing type gains a read-only field to hold the instance of the agent. This field is, by default, named after the agent. To override the name of the agent's field, the as keyword can be used:

class SomeClass with MyAgent as myCustomFieldName {}

So why this agent concept?

Agents address the need for code reuse in Serulian, without the problems associated with inheritance, mixins or simple composition. By being distinct objects (rather than parent types), agents do not fulfill the is-a predicate, instead fulfilling the has-a predicate. This avoids inheritance issues such as the deadly diamond of death problem, as well as mixin issues such as mixins clobbering each other's members. By maintaining a well-typed back-reference to the composing type, agents address the main frustration associated with Golang-style struct composition, allowing agents to act on behalf of their composing types in a safe manner.

Working with agents

from "github.com/serulian/debuglib" import Log interface MyInterface { property SomeInt int { get } } agent Calculator for MyInterface { function Calculate() int { return principal.SomeInt + 20 } } class SomeClass with Calculator { constructor Declare() { return SomeClass{ Calculator: Calculator.new(), } } property SomeInt int { get { return 22 } } } function Run() { var sc = SomeClass.Declare() Log(sc.Calculate()) }

Structs

Structural types ("structs") in Serulian are structural, read-only containers for holding data.

Why not just use classes?

As we've seen from libraries such as ImmutableJS, immutable state is important when working on the web and mobile devices.

Single page applications are often driven by APIs that involve large amounts of data serialization and deserialization. While classes (and interfaces) allow for hand-writing this serialization, structs provide a standard, safe and efficient way for defining pure data.

In addition to serialization, there are a number of cases in-app where having pure, unalterable data is necessary, such as working with asynchronous functions across web workers, or storing state for components. In these cases, having a type that is defined to be read-only is a huge benefit to safety.

Structs are defined using the struct keyword on a type:

struct MyData {}

Unlike classes, interfaces and nominal types, structs can only contain a single kind of member: read-only fields.

Declaring structs

Fields are declared inside a struct in a manner similar to parameters, with the name of the field followed by the type of the field:

struct MyData { FirstField int }

Multiple fields are simply placed after one another:

struct MyData { FirstField int SecondField string ThirdField int? }

Fields in a struct must themselves be structural (or a primitive). This is to ensure that structural data is always read-only and serializable:

class SomeClass { } struct MyData { SomeField SomeClass // Fail! Won't compile because SomeClass is not a struct. }

Fields within a struct can be marked as non-required by making their type nullable:

struct MyData { NotRequiredField int? }

Likewise, fields in a struct can also have a default value:

struct MyData { NotRequiredFieldSinceHasDefault int = 42 }

Creating new instances of a struct

Structs are created using a structural new expression likes classes:

struct MyData { FirstField int SecondField string myPackagePrivateField int? } var myData = MyData{ FirstField: 42, SecondField: 'hello world!', }

All fields that are non-nullable and that do not have a default value are marked as required and must therefore be given a value.

Modifying a struct

Since structs are, by definition, immutable, they cannot be modified. Instead, Serulian provides a native clone-with-changes operation, with syntax similar to the structural new syntax:

struct MyData { FirstField int SecondField string myPackagePrivateField int? } var myOriginalData = MyData{ FirstField: 42, SecondField: 'hello world!', } var myChangedData = myOriginalData{ FirstField: 43, // Only change FirstField }

Serializing structs

All structs in Serulian can be serialized and deserialized to/from textual and other formats. The default format supported in Serulian is json:

struct MyData { FirstField int SecondField string myPackagePrivateField int? } function SerializeThing(myData MyData) { var jsonData = myData.Stringify<json>() // Serializes to JSON var myDataCopy = MyData.Parse<json>(jsonData) // Deserializes from JSON }

The deserialization method Parse will verify that all required fields are present and that all fields have their matching type. If there is a mismatch, the method will reject with an error, preventing the bad struct from being created.

What if I don't like JSON?

What?! I mean, sure... If you want to define your own implementation for serialization or deserialization of structures, you can implement the Stringifier or Parser interfaces (respectively). In fact, this is how JSON is implemented!

Custom names for serialization

If a custom name is required for a struct field for serialization/deserialization, the syntax `name:"somefield"` can be used:

struct MyData { FirstField int `name:"firstfieldhere"` }

Comparing structs

Structural types in Serulian implement an implicit Equals operator, which allows for comparing structs in a structural, recursive fashion:

struct MyData { TheField int } MyData{TheField: 42} == MyData{TheField: 42} // True! MyData{TheField: 42} == MyData{TheField: 53} // False!

Working with structs

from "github.com/serulian/debuglib" import Log struct Nested { NestedString string } struct MyStruct { FirstField int SecondField string `name:"second"` OptionalField string? NestedField Nested? } function Run() { // Deserialize from JSON. jsonData := `{ "FirstField": 42, "second": "hello world!", "NestedField": { "NestedString": "I'm nested!" } }` myStruct := MyStruct.Parse<json>(jsonData) Log(myStruct) // Construct inline. myConstructedStruct := MyStruct{ FirstField: 42, SecondField: "hello world!", NestedField: Nested{ NestedString: "I'm nested!", }, } // Compare. Log(myStruct == myConstructedStruct) // Serialize to JSON. Log(myConstructedStruct.Stringify<json>()) // Attempt to deserialize invalid JSON. invalidData := `{ "FirstField": "not an int" }` myStruct2, err := MyStruct.Parse<json>(invalidData) if myStruct2 is not null { Log('Struct is valid!') Log(myStruct2) } else { Log('Got JSON parse error') } }

Nominals

Nominal types in Serulian are specialized named views of other types, allowing extension of an underlying type without modifying its source. These types present their underlying types with a different interfaces and, usually, additional logic (but not data!).

Defining a nominal type

Nominal types are declared in Serulian via the type keyword, and always requires a single parent type:

type MyType : AnotherType {}

Nominal type members

Nominal types can contain zero or more functions, constructors, properties, or operators, but, like interfaces, cannot contain variables, as they are merely a view over the underlying type's state.

Why have nominal types?

Nominal types are used in Serulian as a way of changing the interface presented by a different type, typically types pulled in from JavaScript. For example, all of the "primitives" that Serulian defines (string, int, etc) are, in fact, nominal types defined over the primitive types exported by JavaScript (DOMString, Number, etc). This allows for the same types to be shared between JS and Serulian code, but with the added benefit of a better interface when working in Serulian.

Converting to and from nominal types

A nominal type can be converted to and from its parent type by using the name of the nominal type as a function call:

class AnotherType {} type MyType : AnotherType {} function DoSomething(at AnotherType) { var myType MyType = MyType(at) var anotherType AnotherType = AnotherType(myType) }

If a type cannot be converted to/from the nominal type, it will result in a type error at compile time.

Writing a nominal type

Since a nominal type cannot contain its own state, all operations defined on the type must typically use either another operation on the same type, or an operation on the parent type.

For example, if we wanted to change the GetSomeInt function to a SomeInt property, we might implementing it this way:

class AnotherType { function GetSomeInt int { return 42 } } type MyType : AnotherType { property SomeInt int { get { return AnotherType(this).GetSomeInt() // Note the conversion of `this` to AnotherType } } }

Note that unlike composition, the members of the parent type are not accessible on the nominal type; it presents a completely new interface.

Nominal root

There are times (especially when working with wrapped JS types) when it is necessary or useful to get the parent instance of a nominal type instance. As nominal types can wrap other nominal types, Serulian provides the ampersand operator & to get the instance of the root type of a nominal type instance:

class AnotherType { ... } type MiddleType : AnotherType { ... } type MyType : MiddleType { ... } function DoSomething(myTypeInstance MyType) AnotherType { return &myTypeInstance // Returns the instance of AnotherType that MyType is wrapping via MiddleType }

The nominal root operator can also be used on values of type any, in which case it will return at runtime the root instance.

Nominal type auto-conversion

If an instance of a nominal type is used as an argument to a call where one of its parent types is expected, the compiler will auto-unbox the nominal type into the necessary type:

class AnotherType { ... } type MiddleType : AnotherType { ... } type MyType : MiddleType { ... } function otherFunction(anotherType AnotherType) {} function DoSomething(myTypeInstance MyType) { otherFunction(myTypeInstance) // Works and is auto-converted into AnotherType. }

This is a convenience feature provided to make working with APIs nicer without having to use the nominal root or nominal conversion calls everywhere.

Use of the nominal root operator

The nominal root operator is very useful when working with JavaScript-wrapped types and calling into JavaScript code (via WebIDL). However, care should be taken when using the operator, as the code produced isn't obviously readable and can appear confusing to users who are unaware of the operator and its purpose. Whenever possible, use of the operator should be kept within private implementations and documented well.

Working with nominals

from "github.com/serulian/debuglib" import Log type MyInt : int { property IsEven bool { get { return int(this) % 2 == 0 } } } type MyBetterInt : MyInt { property IsOdd bool { get { return !MyInt(this).IsEven } } } function Run() { var myInt = MyInt(42) Log(myInt.IsEven) Log(MyBetterInt(myInt).IsOdd) var anotherInt = MyInt(17) Log(anotherInt.IsEven) Log(MyBetterInt(anotherInt).IsOdd) Log(&myInt) // Raw JS Number }

Generics

All Serulian types are capable of being made generic to allow for type-safe reuse.

Declaring generics on a type

Generics are declared using the <T> syntax, similar to the type declarations on type members:

class MyCollection<T, Q> { ... }

Using generics within a type

Once declared, the generic can be used within the parent type wherever another type is expected:

class MyCollection<T, Q> { function Get(key T) Q { ... } }

Declaring constraints on a generic

By default, a generic type can be given any type (concrete or otherwise). To restrict the types that can be specified in a generic, an interface can be specified as a constraint:

class MyCollection<T : MyInterface> { ... }

If specified as such, then any instance of the generic type can have the members of the interface called:

interface MyInterface { property IntValue int { get } } class MyCollection<T : MyInterface> { function SomeFunction(value T) int { // Allowed because `T` is constrained to any type that implements interface `MyInterface` return value.IntValue } }

As well, if any constructors are defined on the interface, they can be called on the generic type:

interface MyInterface { constructor Build() { return SomeClass.new() } } class MyCollection<T : MyInterface> { function SomeFunction() { T.Build() // Allowed because `Build` is declared on `MyInterface` } }

Interface constructors and generics

The above pattern is particularly useful for interfaces that will define default and preferred implementations of themselves. For example, a Cache interface might declare that its Create constructor return, by default, an in-memory cache. A class which defines a generic C : Cache can then create the cache simply by calling C.Create(). By making the type generic, the class enables callers to override this behavior by specifying a generic type that implements the Cache interface, but instead constructs another kind of cache. This pattern is also quite useful for injecting mocks for testing.

Using generics at runtime

Unlike some languages (like Java), generic type information is maintained at runtime by Serulian. This means that while the types are verified at compile time, the generic type var(s) of a type are available at runtime to be used for such operations as casting:

class MyCollection<T> { function SomeFunction(value any) { value.(T) // Will fail at runtime if `value` does not have a type matching T } }

Working with generics

from "@core" import List from "github.com/serulian/debuglib" import Log function Run() { var myList = List<int>.Empty() myList.Add(123) myList.Add(456) // Uncomment this to see a type error: // myList.Add('hello world') Log(myList[0]) }

Type Members

Serulian supports constructors, variables, functions, properties, and operators on types.

Constructors

A constructor is a static function found on a class, interface or nominal type that creates an instance of that type.

Defining constructors

Constructors are defined using the constructor keyword, a name, and an optional list of arguments for the function:

class SomeClass { constructor CreateMe(firstParam int, secondParam string) { ... } }

All constructors must return an instance of the type being constructed.

Calling constructors

Constructors are called by name under the parent type:

class SomeClass { constructor CreateMe(firstParam int, secondParam string) { ... } } var sc SomeClass = SomeClass.CreateMe(1, 'hello')

Being functions, constructors can also be aliased:

class SomeClass { constructor CreateMe(firstParam int, secondParam string) { ... } } function DoSomething() SomeClass { var f function<SomeClass>(int, string) = SomeClass.CreateMe return f(1, 'hello') // Returns the new instance }

Naming constructors

Constructors should have descriptive names, such that they read when written in their called form: TheType.constructor.

For example, the List collection type has constructors List<T>.Empty(), List<T>.CopyOf(otherList) and List<T>.Combine(firstList, secondList). In all three cases, it is clear what the state of the List will be once constructed.

Variables

A variable is a field on a class that holds a value.

Defining variables

A variable is declared using the var keyword, its name, its type and an optional default value:

class SomeClass { var myVariable string var myVarWithDefault int = 42 }

Accessing variables

Variables are accessed under the instance of the class:

class SomeClass { var myVariable string function DoSomething() { this.myVariable } }

Changing the value of variables

The value of a variable can be changed using the assignment operator =:

class SomeClass { var myVariable string function DoSomething() { this.myVariable = 'hello world' } }

Working with variables

from "github.com/serulian/debuglib" import Log var someVar string = 'hello world!' function Run() { Log(someVar) someVar = 'new value' Log(someVar) }

Functions

A function is an instance function found on a class, interface or nominal type. Functions declared on interfaces do not have implementations.

Defining functions

Functions are defined using the function keyword, its name, its return type, and an optional list of arguments for the function:

class SomeClass { function DoSomething(firstParam int, secondParam string) { ... } function ReturnsSomething(firstParam int, secondParam string) int { ... } }

If the function is not intended to return a value, the return type specified is void or left off entirely.

Calling functions

A function can be called on an instance of the class, interface or nominal type:

class SomeClass { var myVariable string function AnotherFunction() {} function DoSomething() { this.AnotherFunction() } }

Functions can also be aliased:

class SomeClass { function AnotherFunction() {} } function DoSomething(sc SomeClass) { var f = sc.AnotherFunction f() // Calls the function. }

What about the scope of this when aliased?

Serulian handles this for you. this will still refer to the parent class, even if aliased in the above manner.

Working with functions

from "github.com/serulian/debuglib" import Log class SomeClass { function LogMessage() { Log(this) Log('hi there!') } } function Run() { sc := SomeClass.new() sc.LogMessage() // Call the function on the class. }

Properties

A property is a set of instance functions found on a class, interface or nominal type that act as if they are a single value. Properties declared on interfaces do not have implementations.

Defining properties

Properties are defined using the property keyword, its name, its type, and then a getter and (optionally) a setter:

class SomeClass { property ReadOnlyProperty int { get { return 42 } } }

When defining a read-write property, a set block is added and the special keyword val is used to get the value in that block:

class SomeClass { property ReadWriteProperty int { get { return 42 } set { var valueToBeSet = val // `val` holds the value being set. } } }

Accessing properties

Properties are accessed under the instance of the class:

class SomeClass { property myProperty int { get { return 42 } } function DoSomething() { this.myProperty } }

Changing the value of properties

The value of a property can be changed using the assignment operator = if it has a set block declared:

class SomeClass { property myProperty int { get { ... } set { ... } } function DoSomething() { this.myProperty = 42 } }

Working with properties

from "github.com/serulian/debuglib" import Log class SomeClass { property SomeProperty string { get { return 'hello world!' } } } function Run() { sc := SomeClass.new() Log(sc.SomeProperty) }

Operators

An operator is a (usually static) specialized function found on a class, interface or nominal type that matches a syntactic operation.

Defining operators

Operators are defined using the operator keyword, optionally is return type (if required), its name and its implementation:

class SomeClass { operator Plus(left SomeClass, right SomeClass) { return left } operator Index(index int) bool { return true } }

Supported operators

Operators must match a predefined set of names and return types. The full list can be found specification repository.

Accessing operators

An operator is accessed using its syntax. For example, the plus operator is invoked when two instances of the type defining that operator are placed around a plus operator +:

class SomeClass { operator Plus(left SomeClass, right SomeClass) { return left } } function DoSomething(first SomeClass, second SomeClass) { first + second // Invokes the `Plus` operator on `SomeClass` }

Working with operators

from "github.com/serulian/debuglib" import Log class SomeClass { var message string operator Plus(left SomeClass, right SomeClass) { return SomeClass{ message: left.message + '+' + right.message, } } } function Run() { l := SomeClass.new('hello') r := SomeClass.new('world!') combined := l + r Log(combined.message) }

Streams and Iterators

Serulian has native support for a concept known as a stream, a (perhaps infinite) series of instances of a type.

Streams are declared using a type, followed by the asterisk operator: SomeType*.

Hey! That looks familiar...

Indeed! Together with nullable types, the type declaration syntax in Serulian mirrors regular expressions:
SyntaxMeaning
SomeType Exactly one instance of SomeType
SomeType? Zero or one instance of SomeType (if zero, is null)
SomeType* Zero or more instances of SomeType

What is a stream in Serulian?

A stream in Serulian is, simply put, a type matching the Stream interface. This interface provides a single method, Next, which returns a tuple containing the next instance in the stream and whether such an instance was found.

How do I use a stream?

Streams can be used in Serulian via the for statement:

function DoSomething(someIntStream int*) { for value in someIntStream { // `value` holds the current value. } }

How do I declare a stream?

Streams in Serulian can be declared by either manually implementing the Stream interface (like IntStream does), or by writing an iterator.

Iterators

An iterator is any function or property that uses the yield keyword instead of the return keyword in its body.

Iterators allow for easy definition of state machine-like streams, without having to manually maintain the associated state.

Declaring an iterator

To declare an iterator, simply make the return or value type of a function or property into a stream, and then use yield keyword once (or more) to yield values:

function MyCoolIterator() int* { yield 1 yield 2 yield 3 }

The above iterator, when called, will return a stream of int. Looping over this stream will yield three elements (1, 2, and 3) and then the stream will be empty.

Yielding from another stream

Sometimes it is useful to yield all the elements from another stream from within an iterator. Rather than having to write a for+yield, Serulian provides the yield in syntax honey:

function MyCoolIterator(anotherStream int*) int* { yield 1 yield in anotherStream yield 3 }

The above iterator, when called, will return a stream of int. Looping over this stream will yield 1, all values found in the other stream, and 3.

Terminating an iterator

To terminate an iterator's stream, the yield break call can be used:

function MyCoolIterator() int* { yield 1 yield break yield 3 }

The above iterator, when called, will return a stream of int. Looping over this stream will yield only 1, as the stream is terminated following that yield.

Working with iterators

from "github.com/serulian/debuglib" import Log function myIterator() string* { yield 'I am the very model' yield 'of a modern major general' yield 'who loves to sing!' } function Run() { for piece in myIterator() { Log(piece) } }

Statements

Return

A return statement terminates execution of a function or property, optionally returning a value.

function SomeFunction() int { return 42 }

Reject

A reject statement is used for error handling and terminates execution of a function or property, indicating an error or exception has occurred. All values rejected must implement the Error interface.

function SomeFunction() int { reject SimpleError.WithMessage('oh no! Something went wrong!') }

Variable

A var statement defines a variable with a name in the context. Its value is optional if it has an initializing expression.

var someVar = 42 var anotherVar int = 42 var nullableVar int?

Assignment

An assignment statement assigns a value to a variable or property.

var someVar = 42 someVar = 56

Resolve

A resolve statement assigns a value to a read-only name, and optionally handles catching rejections from any child expressions.

Assigning a single value

The simplest form of a resolve statement assigns the result of its expression to a name:

someValue := 56

In the above example, the name someValue will be given the value 56 and is read only

When should a resolve statement be used?

Simple resolution to names should be used if the name being assigned will never change. In most code, "variables" never, in fact, vary, making it safer to use a resolve statement. This prevents accidental overwriting of the existing value, which can break expectations.

Handling rejection

What's rejection?

If you haven't yet, please read the section on error handling.

If a second name is specified in a resolve statement, then the resolve statement is said to accept rejection, only filling in the first name if the value expression does not reject:

someValue, err := SomeOtherFunction()

In the above example, if the call to SomeOtherFunction rejects with an error, the error will be placed into the err name and the someValue value will be null. If the call succeeds, then someValue will be the value returned by the function and err will be null. It is typical to follow such a call with a conditional check:

someValue, err := SomeOtherFunction() if err is not null { // Handle the error here. `err` will have the error and `someValue` will be null. }

Ignoring errors or values

If the error (or value) returned by a resolve statement should be ignored, the anonymous name operator _ can be used:

someValue, _ := SomeOtherFunction()

In the above example, we've asked the compiler to accept all errors that may occur, but we don't care about the contents of the error. It is typical to follow such a call with a conditional check to see if the left hand value (someValue) is null

Alternatively, the value itself can be ignored:

function SomeFunction() int { _, err := SomeOtherFunction() }

Like the previous example, it is typical to use a null check on the named value to determine what occurred.

Conditional

A conditional statement allows for branching based on a bool condition:

if someBool { // Only executes if `someBool` is true }

A conditional statement can have an else block for the case when the condition is false:

if someBool { // Only executes if `someBool` is true } else { // Only executes if `someBool` is false }

Conditional statements can also be chained together:

if someBool { // Only executes if `someBool` is true } else if anotherBool { // Only executes if `someBool` is false and `anotherBool` is true. }

Loop

A loop statement allows for looping.

Looping forever

A for loop without an expression will loop forever:

for { // Loops until end of time. }

The loop can be terminated via a return statement, reject statement or a break statement:

for { break // Terminates the loop. }

Looping while a conditional is met

A for loop with a single expression will loop while the expression is true:

for someExpression { // Loops until `someExpression` is `false`. }

Looping over a stream

A for in loop allows for looping over the values found in a stream:

for value in someStream { // `value` holds the current value in the stream. }

The name given before the in keyword will hold the current value in the stream.

What about a "normal" for loop?

How do I loop over a range of numbers?

Serulian has no concept of a "normal" for loop like other languages. Instead, the range operator is used in conjunction with streams and the single loop syntax to accomplish the same goal:

for value in 0..2 { // `value` will range from 0 to 2 }

This was a deliberate design choice, as numeric iteration can now be treated like any other form of iteration of streams. In fact, other types can define their own implementations of the range operator .., allowing for all sorts of iteration!

Switch

A switch statement is a compacted set of chained "conditional" statements, with slightly different syntax.

Switching over a value

A switch over a value expression will compare each of its case statements to that value:

switch someString { case 'hello world': // Called if someString == 'hello world' case 'hi universe': // Called if someString == 'hi universe' }

A default statement can also be added, to catch any value that doesn't match the cases listed:

switch someString { case 'hello world': // Called if someString == 'hello world' case 'hi universe': // Called if someString == 'hi universe' default: // Called otherwise }

Note that the default branch must be the last branch in the switch.

Note that unlike other languages a "break" keyword is not necessary, as switch cases do not fallthrough.

Why must the default be last?

Because we think it reads better! Defaults are always evaluated after all other cases have been "checked" and, while this isn't necessarily how switch statements are implemented under the covers, it is how we as developers think they work, so we've decided to enforce this requirement to enhance readability.

Switching over expressions

If an expression is omitted from the switch statement, then each branch will be treated as boolean expression, with the first branch in order that evaluates to true being hit:

switch { case someString == 'hello world': // Called if someString == 'hello world' case someInt == 42: // Called if someInt == 42 and none of the previous cases match default: // Called if none of the cases match }

Match

A match statement is similar to a switch statement, except it operates over the type of an expression:

match someExpression { case string: // Called if `someExpression` is a string case int: // Called if `someExpression` is an int case SomeInterface: // Called if `someExpression` implements SomeInterface default: // Called otherwise }

If a reference to the expression, automatically setup to be the matched type is needed, the as clause can be added:

match someExpression as someValue { case string: // someValue is a `string` here case int: // someValue is an `int` here case SomeInterface: // someValue is a `SomeInterface` here }

When is a match useful?

A match statement is typically used when you have a value of type any and need to perform different actions based on its runtime type.

With

A with statement is a specialized statement used for easy release of a value following its completion. The statement is always declared with an expression whose type must implement the Releasable interface.

When control flow leaves the with statement (naturally or via return or reject), the Release function is immediately invoked on the expression:

with someExpression as someValue { // Do stuff with `someValue` } // As soon as this point is reached, `Release` is called on `someExpression`.

So what do I use this for?

The primary use case for the with statement is managing of resources, expressions that require some form of release or cleanup. Rather than having to manually invoke the cleanup code, the with block ensures that the cleanup occurs, regardless of the flow of execution of the code.

For example, imagine we are using an internal canvas element to determine the primary colors found in an image. We probably want to clean up the element once we're done, so we might write something like this:

// Create an append the canvas element. canvasElement := document.createElement('canvas') document.body.appendChild(canvasElement) // Do some work with the canvas element. ... // Finally, remove the canvas element. document.body.removeChild(canvasElement)

While the above code works in most cases, it fails if any of the statements or expression in the work section rejects with an error: in that case, the removal code will never be called and we're left with an extra canvas element in the DOM. If this code gets called many times over, we could potentially be left with hundreds or thousands of extra elements.

If we instead change the code to use a with, we know we are safe:

with createReleasableElement('canvas') as elm: // Do some work with the canvas element. ...

The createReleasableElement would return a class which, when its Release method is called, removes the element from the DOM.

Working with resources

from "github.com/serulian/debuglib" import Log class SomeResource { function Release() { Log('Released') } property SomeValue string { get { return 'hello world!' } } } function Run() { with SomeResource.new() as sr { Log(sr.SomeValue) } }

Notable Expressions and Operators

Functions and Closures

Aliasing functions

Functions in Serulian are first class, able to be aliased into a variable and therefore called at a later location:

function SomeFunction(foo int) {} function DoSomething() { var someFunction = SomeFunction someFunction(123) // Invokes SomeFunction with 123 as a parameter. }

Functions on types, including constructors, can also be aliased:

class SomeClass { constructor SomeConstructor() { ... } function SomeFunction(foo int) { ... } } function DoSomething(sc SomeClass) { var someFunction = sc.SomeFunction someFunction(123) // Invokes SomeFunction on SomeClass with 123 as a parameter. var someConstructor = SomeClass.someConstructor someConstructor() // Invokes SomeConstructor on the SomeClass class. }

Declaring function types

Function values are defined using the specialized type syntax function<returnType>(param1Type, param2Type, paramNType):

var firstFunction function(int) var secondFunction function<SomeClass>() var thirdFunction function(int, string, bool)>

Creating anonymous functions

Serulian supports the concept of anonymous functions or lambda functions, which can be declared using two different forms of syntax.

Full anonymous functions

The first form of anonymous function supported by Serulian is the full lambda function, which is declared with syntax similar to module level and type level functions, just without a name:

someFunction := function (firstParam string) int { return firstParam.Length }

In the above example, the someFunction name will reference the created function, which can then be invoked with a string parameter.

The return type of most full lambda functions can be unspecified, in which case Serulian will infer it from the returned value(s) (if any):

someFunction := function (firstParam string) { return firstParam.Length } // someFunction is inferred to return an int, because its `return` statement returns an int value.
Expression anonymous functions

The second form of anonymous function supported by Serulian is the expression lambda function, which returns the result of evaluating a single expression:

someFunction := (firstParam, secondParam) => firstParam.Length + secondParam.Length

In the above example, the function created will take in two parameters (firstParam and secondParam) and will have a return type of int, as inferred from the expression.

Anonymous functions and binding (Closures)

If a variable from the parent scope is used in an anonymous function, the name is automatically bound to the reference of the variable, not the value:

var someVar int = 12 someFunction := function () { return someVar } someVar = 42 someFunction() // Returns 42, because the value of `someVar` changed.

In the above example, because the value of someVar changed before the function was executed, the returned value is 42.

Closures and loops

The behavior of function closures be an especially unexpected behavior when making use of function closures inside a loop:

var theFunction function<int>()? = null for i in 0..5 { // Only create the function on the first iteration. if i == 0 { theFunction = function () { return i * 2 } } } theFunction() // Prints 10

Some users would expect the value returned to be 2, instead of 10. However, since the variable i is bound inside the function by reference, when the function is invoked after the loop, the value of i is now 5, making the returned result 10.

Template Strings

Like ECMAScript 6, Serulian supports the concept of template literal strings, which allow for safe and readable construction of strings.

Basic template strings

Template string literals in Serulian are constructed using the backtick operator ` and can span multiple lines:

`I am a very long multiline and awesome template string`

Including expressions

Expressions can be included in template strings using the ${expression} syntax. All expressions placed into a template string must implement the Stringable interface, which ensures they can be converted into a string:

adjective := 'phenominal' `This is a ${adjective} template string!` // Returns "This is a phenominal template string!"

Custom handling of template strings

In addition to producing a concatinated string by default, template string literals can also be used to call custom concatination and formatting functions, by placing the name of the function to invoke before the template string literal:

adjective := 'best' myFormatFunction`This is the ${adjective} template string`

The function declared must take in a slice of strings ([]string) as its first parameter and a slice of Stringable's ([]Stringable) as its second parameter:

function myFormatFunction(pieces []string, values []Stringable) string { ... } adjective := 'best' myFormatFunction`This is the ${adjective} template string`

The function can then assemble the string (or even some other type!) however it sees fit, with each piece of the template string, followed by its value found in each of the slices.

Ordering of data

The pieces and values slices will contain each string literal piece and expression value, respectively and in order, as found in the template string. For example, given the following template string:

myFormatFunction`This is an ${adjective} template string that does ${anotherAdjective} things!`
Indexpiecesvalues
0 'This is an ' adjective
1 ' template string that does ' anotherAdjective
2 ' things!' (Slice only has 2 elements)

Working with template strings

from "@core" import Stringable from "github.com/serulian/debuglib" import Log function myFormatter(pieces []string, values []Stringable) string { var value = '' for i in 0..pieces.Length - 1 { value = value + '[' + pieces[i] + ']' if i < values.Length { value = value + '(' + values[i].String() + ')' } } return value } function Run() { firstValue := 2 secondValue := true thirdValue := 'hello world!' Log(`Is it ${secondValue} that I've said ${thirdValue} ${firstValue} times?`) Log(myFormatter`Is it ${secondValue} that I've said ${thirdValue} ${firstValue} times?`) }

Dynamic access

When working with native code such as JavaScript, it is quite useful to have access to the formalized types by making use of WebIDL.

There are times, however, when a full interface definition for the external dependency or for a value of type any is not available. In such cases the dynamic access operator -> can be used to safely access and invoke members on the value.

Basic dynamic access

The dynamic access operator -> performs the lookup of a member on a value at runtime, skipping the normal type checking associated with member access under a type. It can be used on any value and, being dynamic, itself returns a value of type any:

function SomeFunction(someValue any) { // Get the length of the value (if it exists): someValue->length }

In the above example, the expression will return the value found on the length property of the someValue variable, if such a property exists. If the property does not exist, or someValue is null, the value returned will be null.

Using the dynamic access operator

The dynamic access operator is an incredibly powerful tool when working with a value of either unknown type or of a type that comes from JavaScript. Its use, however, should be carefully weighed, as it has a number of major downsides:

  • The returned type is itself any, meaning any further access will need to make use of the operator, cast the value to another type, or use a match statement.
  • The dynamic access operator cannot be used on a type known at compile type, so if the declared type of the value changes, all accesses will need to be changed.
  • Due to the dynamic nature of the access, all dynamic access is treated as potentially accessing any member with that name, significantly reducing the kinds of optimizations the compiler can perform.

It its highly recommended to avoid the dynamic access operator if a WebIDL specification for the native type being used can be written or found.

Error Handling

Error handling is performed in Serulian by a concept called rejection. Rejection is defined as a function, property or other logical block deciding that it cannot continue execution and therefore deciding to terminate, rather than continuing or returning a value.

Rejecting within a function

Any code block can reject by using the reject statement at any point:

function SomeFunction() int { reject SimpleError.WithMessage('Not implemented!') }

All rejection must be given a rejection error value, which must implement the Error interface.

Accepting rejection

By default, rejection bubbles up from a function to the root call of the application. However, there are many times when the caller wishes to accept rejection and take action accordingly. To do so, the resolve statement can be used to accept a rejection and process it:

function SomeFunction() int { reject SimpleError.WithMessage('Not implemented!') } function CallingFunction() { result, err := SomeFunction() }

In the above example, the err variable will be filled in with the SimpleError with which SomeFunction rejected. Since rejection is explicitly accepted here, it will not bubble up.

Why reject and not raise or throw?

The reject statement keyword and the rejection naming was explicitly chosen to match promises, upon which Serulian is built. In addition, we felt that throw and raise applied more to an exception rather than a simple error.

Why doesn't Serulian have checked excep... errors?

We're exploring that now! We haven't quite figured out the syntax and haven't quite figured out how to make the developer experience overly unannoying, but as soon as we have a good design, we plan to add it! It is always better to be explicit, especially around errors.

Have a strong opinion? Please Contribute!

Asynchronous code

Background

Asynchronous code is a cornerstone of writing modern, scalable web and mobile applications. Unlike most traditional applications, which rely on synchronous, but parallel systems such as threads or processes, the majority of web applications make use of asychronous function calls, typically handling the results of operations via callbacks:

// Traditional asynchronous handling in JavaScript:
doSomeAsyncOperation(function(result) {
	// Handle the result here.	
});

However, while this solution is workable, it can rapidly lead to what is colloquially known as callback hell, when multiple calls become nested:

doSomeAsyncOperation(function(result) {
	if (result) {
		doSomeOtherAsyncOperation(function(result2) {
			if (result2) {
				doSomeThirdAsyncOperation(function(result3) {
					// And so on.
				})
			}
		});
	}
});

This problem has plagued web developers for many years, with many different solutions being proposed, mostly recently promises and specialized syntax in JavaScript.

Promises

Recently introduced, promises are an attempt to reduce the friction of working with asynchronous code by allowing for the chaining of asynchronous calls:

doSomeAsyncOperation().then(doSomeOtherAsyncOperation).then(doSomeThirdAsyncOperation).then(function(result) {
	// Only executed if *all* the operations ran.
});

If given a second parameter on the then handler (or a catch handler is declared), errors can also be handled:

doSomeAsyncOperation().then(doSomeOtherAsyncOperation).then(doSomeThirdAsyncOperation).then(function(result, err) {
	// Either result or err will have a value.
});

A promise is said to reject (sound familiar?) if any of the calls raise an exception in JavaScript.

So why not just use promises?

You can! In fact, all of Serulian's asynchronous handling is built on top of promises. As we'll see shortly, this allows us the power of promises while making the code far more readable.

Async/Await

An fairly new feature found in the most recent releases of JavaScript, async/await allows code to be written as-if it were synchronous, even when calling asynchronous code:

var result = await doSomeAsyncOperation()
if (!result) {
	return;
}

result = await doSomeOtherAsyncOperation();
if (!result) {
	return;
}

result = await doSomeThirdAsyncOperation();

This has the nice property of making the code far more readable and easier to understand!

Unfortunately, async/await code suffers from a few major problems: verbosity, safety and coloring.

Verbosity

Having to put the keyword await in front of every asynchronous function call and having to put the keyword async on every asynchronous function leads to code bloat:

async function doSomething() {
	await doSomeAsyncOperation()
	await doSomeOtherAsyncOperation()
	await doSomeThirdAsyncOperation()
}

Safety

If you forget to put the await keyword in front of an asychronous function, then that function will execute, but your code will not wait for it to complete. Instead the value "returned" with be a promise, which can lead to subtle bugs:

async function doSomething() {
	doSomeAsyncOperation() // Whoops! Forgot `await`! Going to immediately return a promise.
	await doSomeOtherAsyncOperation()
	await doSomeThirdAsyncOperation()
}

Coloring

Sadly, not coloring in the fun sense. Functions which await are, by definition, asynchronous, which means they must be marked with async. In turn, any functions calling these functions must also be marked, and so on, and so forth. What happens if a function deep in your code suddenly needs to make an asychronous call and wait on its result? Now all the calling functions must be changed. This is known as the coloring problem, as synchronous and asynchronous functions have different colors.

Practical Asynchronous Code

So how does Serulian solve this problem? Simple: every function in Serulian is (hypothetically) asynchronous!.

Serulian and Promises

While a detail of the code generator, it is useful to know that every function generated by Serulian is created as a promise, unless it can be statically determined that the function never awaits another function's result.

Writing "asynchronous" code in Serulian

As a result of the above decision, writing asynchronous code in Serulian becomes much simpler and safer. Consider the previous example, where we wish to await three functions that are asynchronous. In Serulian, we'd simply call them:

doSomeOperation() doSomeOtherOperation() doSomeThirdOperation()

The Serulian compiler will determine whether the call to be made is synchronous or asynchronous, based on the calls made inside the functions being invoked. If the function itself becomes asynchronous, Serulian will change the call into an await for you!

But what if I have a promise?

Let's say you have a promise, constructed in either Serulian or in JavaScript that you wish to await on. To do so, Serulian provides the arrow operator <-, which, like Go, will "block" (really await) the execution of the current function until the Promise resolves or reject:

function doSomething(p promise<int>) int { // Wait for the promise to resolve. result <- p return result * 2 }

In the above example, the code in doSomething will wait for the promise p to resolve and, if it does successfully, continue execution to the next line. If the promise rejects, on the other hand, the rejection will "bubble up" the call stack.

Handling rejection

If you desire to handle the potential rejection of a promise, another argument can be added before the arrow operator:

function doSomething(p promise<int>) int { // Wait for the promise to resolve. result, err <- p if result is not null { return result * 2 } return 42 }

Just like the resolve statement, a value will be either placed into result or err, with the other being null.

Asynchronous Functions

There will be times, however, that truly asynchronous code is necessary, and simply writing a promise is insufficient. For example, heavy calculations or graphics rendering, which can block the main execution thread. In these cases, it would be ideal for this work to occur in another thread or process. While web browser do not grant access to threads or processes, they do provide web workers.

Web workers

A web worker is a lightweight thread that can be created in the browser. Unlike normal application threads, web workers cannot share memory with the function that spawns them, which, while alleviating concurrency concerns, can make them a pain to work with.

Recognizing this friction, Serulian provides a native API for starting and running code inside of web workers, to provide for easy asynchronous execution.

Defining an asychronous function

In Serulian, to define a module-level function as asynchronous and, therefore, executed inside of a Web Worker, we simply append the name Async to the function name:

function doSomethingAsync() int { ... }

Once modified, the function can be called and we can await on its result with the arrow operator:

function doSomethingAsync() int { ... } function callSomeFunction() int { return <- doSomethingAsync() // Will start the web worker, run the function, and wait for its result. }

Since the function runs in a web worker (and therefore, all data must be serialized and deserialized), all arguments and return types must be structural. The compiler will enforce this restriction (for safety), as well as warning if the function is invoked and not awaited.

Why is this feature enabled by name and not a keyword?

Readability at the call site. By including the Async suffix in the function's name, it ensures that whenever the function is called it is super clear that it is an asynchronous function that should be awaited:

function callSomeFunction() int { doSomethingAsync() // Clearly an async call. }

Why only module-level functions?

Since all data sent to a web worker must be serializable, class-level functions could not be allowed, as we'd have to serialize the this implicit argument, which is unlikely to succeed.

Why must all data be serializable?

A requirement of web workers to make sure they are concurrently safe. As MDN states:

Data is sent between workers and the main thread via a system of messages - both sides send their messages using the postMessage() method, and respond to messages via the onmessage event handler (the message is contained within the Message event's data attribute.) The data is copied rather than shared.

Serulian Markup Language (SML)

Background

A great deal of code in today's modern web and mobile applications revolves around the creation, modification and overall management of the Document Object Model (DOM). While modifying the DOM directly from within code is possible (and in fact, has been the standard for quite some time), the code produced can often be difficult to read and follow; heck even creating a simple table can be quite verbose:

function createDom() {
	var table = document.createElement('table');
	var tbody = document.createElement('tbody');
	var row = document.createElement('row');
	var col1 = document.createElement('col1');
	// and so on and so forth for *many* more lines.
}

JSX and TSX

Recognizing the difficulty in reading such code, extensions such as JSX (and TSX for TypeScript) have been created, which allow for inline declaration of DOM structures in an "HTML-like" syntax:

const element = <h1>Hello, world!</h1>;

While the above syntax makes working with the DOM much easier, it does have a few small downsides:

  • JSX and TSX is tied to the specific DOM library being used (in the above case, React). It cannot be used for non-React code without changing a compilation flag.
  • JSX and TSX make use of naming case to determine whether the element being created is an HTML tag (lowercase) or a custom class (Uppercase). While this in theory leads to more readable code, in practice, it leads to less safety, as any lowercase name is "accepted" as an HTML tag, meaning a mistake in the naming can result in unexpected behavior.
  • It is quite hard to handle conditional tags, as JSX/TSX are expressions.

Serulian Markup Language

Recognizing the importance of being able to define readable code for working with the DOM (but not wanting to be locked to a specific provider) we have chosen to implement a markup syntax entitled Serulian Markup Language, which is not bound to a specific downstream library, but is, instead a general-purpose expression building system with its own syntax.

Tags in SML

SML is, at its core, simply a different syntax for constructing and invoking function calls. Each tag in SML is a call to a function (or a constructor named Declare on the class), with attributes and contents being given to the function (or constructor) being called:

function SomeFunction() int { ... } class SomeClass { constructor Declare() { ... } } <SomeFunction /> // Calls SomeFunction with no arguments. <SomeClass /> // Calls SomeClass.Declare with no arguments.

In the above example, by creating a tag with SomeFunction or SomeClass as its tag "name", we instruct Serulian to invoke the SomeFunction function (or Declare on SomeClass), with the SML expression returning the return value of the function or constructor.

Attributes in SML

If we wish the function to take arguments, they can be supplied as attributes on the tag:

function SomeFunction(props []{any}) int { ... } // Declare that we accept props <SomeFunction foo="bar" baz={1234} /> // Call with a few props

In the above example, we've specified that the function SomeFunction is allowed to accept properties by adding the first argument named props. By writing the type of the parameter as []{any} (a mapping with value type any), the function has indicated to the compiler that attributes with values of any kind may be specified.

Alternatively, if we only wanted to accept properties of a specific type, we could specify a different value type:

function SomeFunction(props []{string}) int { ... } // Declare that we accept *string* props <SomeFunction foo="bar" baz="meh" /> // Call with a few string props

By making the props into a []{string}, the type checker will only allow attributes on the SML tag to have values of type string.

Typed attributes in SML

While generic attributes are useful in many cases, there are times when a function (or Declare constructor) wants to require a specific set of properties. In to do in SML, the props parameter on the function can be changed into a struct:

struct SomeStruct { RequiredParam int OptionalParam string? } function SomeFunction(props SomeStruct) int { ... } // Declare that we accept SomeStruct props <SomeFunction RequiredParam={42} />

In the above example, by making the props into a SomeStruct, we've instructed Serulian to treat the attributes of the SML tag as matching the fields of the structure. As a result, the type system will require any required fields (RequiredParam in this case), while allowing any nullable/default fields to be optional. Furthermore, since the fields of the struct are well-typed, Serulian will verify that the value given for each attribute matches the type declared in the structure.

Boolean attributes in SML

If an attribute is declared without a value, it is treated by SML as a boolean attribute, with its value being inferred to be true if present:

struct SomeStruct { IsCoolThing bool } function SomeFunction(props SomeStruct) int { ... } // Declare that we accept SomeStruct props <SomeFunction IsCoolThing /> // IsCoolThing has no value, so treated as IsCoolThing = true

Children under SML tags

In addition to attributes, Serulian also supports adding children to an SML tag. Children are declared as being supported by adding a second parameter to the function being called:

function SomeFunction(props SomeStruct, children any*) int { ... } // Declare that we accept children <SomeFunction> First child is text {secondChildExpression} <AnotherFunction /> </SomeFunction>

In the above example, by declaring that the children is a stream of any, Serulian will accept any number of any children on the SML tag.

Constraining children of SML tags

If instead we wish to constraint the type of children, we can be more specific on the type allowed:

function SomeFunction(props SomeStruct, children int*) int { ... } // Declare that we accept int children <SomeFunction>{1}{2}{3}</SomeFunction>

In the above example, by changing our accepting type of children to int*, we've told the type system that only children of type int will be allowed.

Single children of SML tags

Constraining the type of children allowed is not the only kind of constraint we can place on the children; if we instead wish to only accept a single chilld of a specific type, we can change the parameter to no longer be a stream:

function SomeFunction(props SomeStruct, child int?) int { ... } // Declare that we accept a single int child <SomeFunction />

In the above example, by changing the child parameter into a nullable int int?, we've told Serulian that tags can have only zero or one children (of type int).

Similarly, if we wish to make the child required, we can simply make it non-nullable:

function SomeFunction(props SomeStruct, child int) int { ... } // Declare that we require a single int child <SomeFunction>{1}</SomeFunction>

Decorators

Decorators are one of the most powerful components found in SML. A decorator is, at its core, simply a function that maps from a value and an option to another value:

function MyDecorator(value int, option bool) int { if option { return value } else { return 42 } }

Once declared, a decorator can then be applied to an SML tag using the decorator operator @:

function MyDecorator(value int, option bool) int { if option { return value } else { return 42 } } <SomeFunction @MyDecorator={true} /<

When applied this way, the equivalent expression executed is:

MyDecorator(SomeFunction(), true)

If multiple decorators are specified, they are applied left-to-right, each decorator building on the value returned by the previous decorator:

<SomeFunction @MyDecorator={true} @AnotherDecorator="hello">
becomes:
AnotherDecorator(MyDecorator(SomeFunction(), 'hello'), true)

So when are decorators useful?

Decorators are useful when the state of the SML tag should be modified by the decorator's value. For example, the virtual dom library defines an If decorator, which returns the decorated value if the condition is true and null otherwise. This allows for easy conditionals to be included in SML declarations:

<Div @If={somecondition}> // Only rendered if `somecondition` is true. Hello World! </Div>
because the equivalent code is:
If(Div([]{any}{}, ['Hello World!']), somecondition)

Loops in SML tags

Using loops via a nested expression

While decorators provide a programmatic and extensible means of defining conditions and other applications, there are times when it is necessary to generate SML tags over a stream of values, rather than a single value.

SML supports the ability to use inline expressions, and with the inline loop expression expression for value in stream, we can loop when inside of SML:

<Ul>{ <Li>{somevalue}</Li> for somevalue in somestream }</Ul>

As we can see, doing so, however, results in a bit of syntactic complexity, requiring braces to embed the looping expression.

Inline loops in SML

Recognizing that this is a constant pattern, SML supports syntax honey for inline loops, which can be used on a tag without braces:

<Ul> <Li [for somevalue in somestream]>{somevalue}</Li> </Ul>

The above example is semantically equivalent to the previous, but with added readability.

Working with SML

from "github.com/serulian/debuglib" import Log function GetNumber(props []{int}, child int?) int { return (props['value'] ?? 42) + (child ?? 0) } function DoubleIf(value int, condition bool) int { return value * 2 if condition else value } function Run() { Log(<GetNumber @DoubleIf={true} />) Log(<GetNumber @DoubleIf={false} />) Log(<GetNumber value={10} @DoubleIf={true} />) Log(<GetNumber value={10} @DoubleIf={true}>{100}</GetNumber>) }

The Serulian Core Library

String

The string type is a nominal type around the native DOM String and represents a unicode string in Serulian. [Code Link]

Integer

The int type is a nominal type around the native Number type and represents an integer value in Serulian. [Code Link]

Float64

The float64 type is a nominal type around the native Number type and represents a 64-bit floating point value in Serulian. [Code Link]

Boolean

The bool type is a nominal type around the native Boolean type and represents a boolean value in Serulian. [Code Link]

Slice<T>

The slice type is a nominal type around the native Array type and represents a read-only slice of a collection in Serulian. [Code Link]

Slice types can be declared with specialized sytax:

var someStringSlice []string = []string{'first value', 'second value'}

Mapping<T, Q>

The mapping type is a nominal type around the native Object type and represents a read-only slice of a Map in Serulian. [Code Link]

Mapping types can be declared with specialized sytax:

var someMappingOfInt []{int} = []{int}{'foo': 1, 'bar': 2}

The keys in Mappings are always strings.

List<T>

The List class represents a readable and writeable list of values.

Creating an empty List

An empty list can be created using the Empty constructor:

var aListOfStrings = List<string>.Empty()

Adding values to the List

Values can be added to the list using the Add method:

var aListOfStrings = List<string>.Empty() aListOfStrings.Add('hello world') aListOfStrings.Add('hi universe')

Creating an inline list

Specialized syntax can also be used to create an inline list literal:

var aListOfStrings = ['hello world', 'hi universe']

Checking if a list is empty

The IsEmpty property on a list can be used to check if it is empty:

var aListOfStrings = List<string>.Empty() aListOfStrings.IsEmpty // Returns `true` because list is currently empty.

The implicit boolean operator can also be used:

var aListOfStrings = List<string>.Empty() if not aListOfStrings { // Only called if the list is empty. }

Iterating the list

Lists are Streamable, so a for loop can be used to iterate them:

var aListOfStrings = List<string>.Empty() for value in aListOfStrings { // Called for each value. }

Checking for a value in a list

To check if a value is in a List, the IndexOf method or the contains operator can be used:

var aListOfStrings = List<string>.Empty() 'some string' in aListOfStrings // Returns true if the list contains the value. aListOfStrings.IndexOf('some string') // Returns non-null index if the list contains the value.

Getting and setting a value

To retrieve a value in the list at a specific index or set a value at a specific index, the indexer can be used:

var aListOfStrings = List<string>.Empty() aListOfStrings.Add('hello world') aListOfStrings.Add('hi universe') aListOfStrings[0] // Returns 'hello world' aListOfStrings[1] = 'yo!' // Sets index 1 to 'yo!'

Indexing out of bounds

Attempt to access or write a value at an index outside the bounds of the list will cause the action to reject with an error.

Getting a slice of the List

Slices of the list can be retrieved using the slice operator:

var aListOfStrings = ['one', 'two', 'three'] aListOfStrings[0:1] // Returns a slice with ['one'] aListOfStrings[0:2] // Returns a slice with ['one', 'two'] aListOfStrings[-1:1] // Returns a slice with ['three', one']

Removing values from the List

Values can be removed from the list using the Remove method:

var aListOfStrings = List<string>.Empty() aListOfStrings.Remove('hello world') aListOfStrings.Remove('hi universe') // List is now empty.

Set<T>

The Set class represents a set of Mappable values.

Creating an empty Set

An empty Set can be created using the Empty constructor:

var aSetOfStrings = Set<string>.Empty()

Adding values to a Set

A value can be added to a Set using the Add method:

var aSetOfStrings = Set<string>.Empty() aSetOfStrings.Add('some string')

Removing values from a Set

A value can be removed from a Set using the Remove method:

var aSetOfStrings = Set<string>.Empty() aSetOfStrings.Remove('some string')

Checking if an element is in a Set

To check if a value is in a Set, the Contains method or the contains operator can be used:

var aSetOfStrings = Set<string>.Empty() 'some string' in aSetOfStrings // Returns true if the set contains the value. aSetOfStrings.Contains('some string') // Returns true if the set contains the value.

Iterating the set

Sets are Streamable, so a for loop can be used to iterate them:

var aSetOfStrings = Set<string>.Empty() for value in aSetOfStrings { // Called for each value. }

Map<T, Q>

The Map class represents a readable and writeable map of Mappable keys to values.

Creating an empty Map

An empty Map can be created using the Empty constructor:

var aMapFromStringToInt = Map<string, int>.Empty()

Adding keys to a Map

A key-value pair can be added to a Map using the indexer:

var aMapFromStringToInt = Map<string, int>.Empty() aMapFromStringToInt['the answer'] = 42

Removing keys from a Map

A key (and its associated value) can be removed from a Map using the RemoveKey method:

var aMapFromStringToInt = Map<string, int>.Empty() aMapFromStringToInt.RemoveKey('the answer')

Checking if a key is in a Map

To check if a key is in a Map, the contains operator can be used:

var aMapFromStringToInt = Map<string, int>.Empty() 'some string' in aMapFromStringToInt // Returns true if the map contains the key.

Iterating the keys of the Map

The Keys property can be used with a for loop to iterate the keys in a Map:

var aMapFromStringToInt = Map<string, int>.Empty() for key in aMapFromStringToInt.Keys { // Called for each key. }

That's it!

Have a question? A feature you'd like to see? Add an issue!

.