Inko 0.18.1 is released
We're pleased to announce the release of Inko 0.18.1. This release includes support for stack allocated types, parsing and formatting of dates and times, the enabling of LLVM optimizations, and more.
If you're new to Inko: Inko is a programming language for building concurrent software, but without the usual headaches such as data race conditions and non-deterministic garbage collectors. Inko features deterministic automatic memory management, compiles to machine code using LLVM, supports different platforms (Linux, macOS and FreeBSD, and potentially any other Unix based platform), and is easy to get started with. For more information, refer to the homepage or the manual.
Table of contents
- The class keyword is replaced with a type keyword
- Support for stack allocated types
- Fields no longer allow assignments by default
- LLVM optimizations are now applied
- Support for resolving DNS names using std.net.dns
- Support for RFC 8305: Happy Eyeballs
- Support for parsing and formatting DateTime values
- Parsing JSON from Read types
- Self types are back
- Fixes for ensuring types are sendable
- Inlining of constants
- Parallel method-local optimizations
- Enums use less memory
- Plans for 0.19.0
This release includes many changes not listed in this announcement. For the full list of changes, refer to the changelog.
A special thanks to the following people for contributing changes included in this release:
The class keyword is replaced with a type keyword
Types used to be defined using the class
keyword. Starting with 0.18.1, the
class
keyword is deprecated in favor of the type
keyword. This means that
instead of this:
class async Main {
...
}
You now write this:
type async Main {
...
}
To make the transition process easier, the compiler supports both the class
and type
keywords and the inko fmt
command automatically replaces the
class
keyword with the type
keyword. This means that to upgrade your project
to use this new syntax, all you need to do is run inko fmt
and commit your
changes.
Support for the class
keyword will be removed in 0.19.0, so make sure to
update your projects before upgrading to 0.19.0 when it's released.
Support for stack allocated types
This version introduces support for types that are allocated on the stack/inline
to their owner. Such types are defined using the inline
keyword when defining
a type. For example:
type inline User {
let @name: String
let @age: Int
}
User(name: 'Alice', age: 42) # This value is on the stack, not on the heap
Stack allocated types are useful for short-lived types or simple wrapper types, and avoid the need for a heap allocation and pointer indirection. LLVM is also better at optimizing stack allocated types compared to heap allocated types.
In addition, one can define a inline type that's trivial to copy using the
copy
keyword:
type copy Time {
let @hour: Int
let @minute: Int
let @second: Int
}
Types defined using the copy
keyword can only contain other copy
types
(= Int
, Float
, Bool
, Nil
, and custom copy
types). Copy types are also
immutable and thus don't allow any fn mut
methods, unlike inline
types.
As part of this change, a variety of types provided by the standard library are
turned into inline
types such as std.option.Option
and std.result.Result
.
To ensure memory safety, borrowing of inline types works a little different compared to heap types. When borrowing a heap type, a single borrow counter is incremented and decremented when the borrow is no longer needed. When borrowing an inline type, the inline/stack data is copied (which itself is cheap) and the borrow counter for each heap type stored in the inline type is incremented. Thus, if an inline type stores 10 heap types (e.g. 10 arrays), borrowing that inline type incurs 10 increments and 10 corresponding decrements.
copy
types don't have this cost because they can't store heap allocated
values. This makes copying them trivial, but also limits their use.
In addition, fields of inline
types can't be assigned new values. Because
borrowing an inline value creates a copy, assigning a field a new value would
mean the assignment is only visible using that exact copy. Because of the
surprising behavior this can lead to, we don't allow fields assignments for
inline types, though we are investigating potential
solutions to this problem.
For more information, refer to the documentation of inline types.
Fields no longer allow assignments by default
Similar to local variables, fields can no longer be assigned new values unless they are explicitly defined as mutable fields:
type User {
let @name: String
let mut @age: Int
}
type async Main {
fn async main {
let user = User(name: 'Alice', age: 42)
user.name = 'Bob' # This isn't allowed because `name` isn't `mut`
user.age = 43 # This _is_ allowed because the field is defined using `let mut`
}
}
This change means it's now possible to expose public fields that can't be assigned new values, something that wasn't possible before.
LLVM optimizations are now applied
Before 0.18.1, running inko build
resulted in no LLVM optimizations passes
running as we had yet to figure out which ones are worth enabling. As part of
this issue we looked into this
and ended up changing things as follows:
inko build --opt=none
doesn't apply any optimizations, meaning it's similar toclang -O0
inko build
/inko build --opt=balanced
applies optimizations similar toclang -O2
, with some small changes to better suit our needsinko build --opt=aggressive
applies optimizations similar toclang -O3
. Most of the time this won't make a difference runtime performance wise, but we may extend the list of optimizations in the future
When running tests using inko test
, optimizations are disabled.
The use of an explicit list of optimizations means you may run into issues
when using LLVM 19 or newer as it appears LLVM 19 made some changes related to
certain optimization passes. If this is the case, you can work around this using
inko build --opt=none
for the time being or build using an older version of
LLVM. For more details, refer to this
issue.
Support for resolving DNS names using std.net.dns
This release includes a new module for performing DNS name resolution:
std.net.dns
.
For example, we can resolve one.one.one.one
into a list of IP addresses as
follows:
import std.fmt (fmt)
import std.net.dns (Resolver)
import std.stdio (Stdout)
type async Main {
fn async main {
let dns = Resolver.new
let out = Stdout.new
out.print(fmt(dns.resolve('one.one.one.one')))
}
}
This produces the following output:
Ok([1.0.0.1, 1.1.1.1, 2606:4700:4700::1001, 2606:4700:4700::1111])
The Resolver
type uses different backends for different platforms. The choice
of backend is determined at runtime, not at compile-time. For macOS and
FreeBSD, the getaddrinfo()
function is used. For Linux we try to detect the
presence of
systemd-resolved
and if present will use its varlink API. This API allows
resolving of DNS names using Inko's non-blocking socket API, which can greatly
improve performance compared to getaddrinfo()
. If systemd-resolved isn't
available, the Linux backend falls back to using getaddrinfo()
.
When using the systemd-resolved backend it's also possible to specify a timeout
for DNS lookups using
std.net.dns.Resolver.timeout_after=
.
This timeout is ignored by the getaddrinfo()
backend as getaddrinfo()
doesn't support custom timeouts.
The getaddrinfo()
backend is implemented using
std.io.start_blocking
and
std.io.stop_blocking
,
ensuring such blocking calls don't exhaust the available (primary) OS threads.
As the number of backup
threads
used for blocking system calls is fixed, it's still possible to exhaust those
threads. For this reason we highly recommend to use systemd-resolved when
deploying to Linux as the systemd-resolved backend doesn't suffer from this
problem.
Support for RFC 8305: Happy Eyeballs
The type std.net.socket.TcpClient
supports RFC 8305 when using
the following methods:
Instead of expecting a single IP address and port, these methods now expect an
array of IP addresses and a port and will use the RFC 8305 algorithm to
efficiently (and as fast as possible) establish a connection. Combined with the
new std.net.dns.Resolver
type, connecting to a hostname is done as follows:
import std.net.dns (Resolver)
import std.net.socket (TcpClient)
type async Main {
fn async main {
let dns = Resolver.new
let ips = dns.resolve('one.one.one.one').or_panic('DNS lookup failed')
TcpClient.new(ips, port: 443).or_panic('failed to connect')
}
}
Support for RFC 8305 does incur a small cost due to the extra bookkeeping that's necessary. If necessary you can avoid this cost by providing an array containing only a single IP address:
import std.net.dns (Resolver)
import std.net.socket (TcpClient)
type async Main {
fn async main {
let dns = Resolver.new
let ips = dns.resolve('one.one.one.one').or_panic('DNS lookup failed')
TcpClient.new([ips.get(0)], port: 443).or_panic('failed to connect')
}
}
As part of these changes, TcpClient.new
is changed to no longer wait
indefinitely for a connection to establish and instead enforces a timeout of 5
seconds. If you need a custom timeout you can use TcpClient.with_timeout
instead.
Support for parsing and formatting DateTime values
The type
std.time.DateTime
now supports parsing and formatting using the following methods:
Both parsing and formatting is locale-aware and the standard library provides locale information for Dutch, English, and Japanese. For example:
import std.locale.en
import std.locale.ja
import std.stdio (Stdout)
import std.time (DateTime)
type async Main {
fn async main {
let out = Stdout.new
let en = en.Locale.new
let ja = ja.Locale.new
let dt = DateTime
.parse('2025-02-12 02:13', format: '%Y-%m-%d %H:%M', locale: en)
.or_panic('failed to parse the input')
out.print(dt.format('%B %d, %Y', locale: ja))
out.print(dt.format('%B %d, %Y', locale: en))
}
}
This produces the following output:
2月 12, 2025
February 12, 2025
In addition, DateTime
is now split into the following types:
std.time.Time
: a type that represents a pair of hours, minutes, seconds and nanoseconds.std.time.Date
: a type that represents the year, month and day in the Gregorian calendarstd.time.DateTime
: a type that combines theDate
andTime
types along with the UTC offset (in seconds)
Parsing JSON from Read types
The types std.json.Parser
(typically used through the std.json.Json.parse
method) and std.json.PullParser
now expect a type that implements std.io.Read
as their input, instead of a String
or ByteArray
. This makes it possible to
parse e.g. a file without first having to read the entire file into memory.
If you want to parse an existing String
or ByteArray
, you have to wrap it in
a std.io.Buffer
like so:
import std.io (Buffer)
import std.json (Json)
import std.stdio (Stdout)
type async Main {
fn async main {
let doc = Json.parse(Buffer.new('{ "name": "Alice" }')).or_panic(
'the JSON is invalid',
)
let name = doc.query.key('name').as_string.or_panic(
'the "name" key is required and must be a string',
)
let out = Stdout.new
out.print(name) # => 'Alice'
}
}
In addition, the type
std.json.ObjectParser
exposes a different API compared to previous version. For 0.19.0 we'll likely
make further changes to the pull parsing API as we're not
satisfied with the current setup.
Self types are back
Self
is a type that acts as a sort of placeholder: when used inside a trait
definition, it refers to the type that implements the trait. When used in a type
definition, it refers to the type itself. Inko used to support Self
types, but
support was removed as part of the 0.11.0 release
due to the complex and buggy implementation.
In this release we reintroduce support for Self
types, but this time using an
implementation that's easier to maintain and less likely to introduce bugs. As
part of this release, the following types have been adjusted to use Self
instead of using generic type parameters:
This means that instead of implementing these types like this:
import std.cmp (Compare, Equal)
import std.clone (Clone)
type Type {}
impl Compare[Type] for Type {
...
}
impl Equal[Type] for Type {
...
}
impl Clone[Type] for Type {
...
}
You implement them like this instead:
import std.clone (Clone)
import std.cmp (Compare, Equal)
type Type {}
impl Compare for Type {
...
}
impl Equal for Type {
...
}
impl Clone for Type {
...
}
In addition, the type of self
in default trait methods is now Self
and you
can no longer cast it to a trait value (i.e. self as ToString
), fixing this
soundness hole.
Fixes for ensuring types are sendable
For a value to be safe to be moved between processes, it must be "sendable". A value is sendable when it's guaranteed the sender retains no references to it and the value retains no references to any data owned by the sender. It's a bit like a carrot in a box: you can move the box around, but you can't look inside the box unless you're willing to give up the ability to move it between processes afterwards.
In 0.18.1 various compiler bugs related to ensuring a value is sendable are fixed. This in turn revealed that in certain cases the checks were in fact too strict, resulting in otherwise valid code being rejected, something we found out after starting work on releasing 0.18.0, hence this announcement is about 0.18.1 and not 0.18.0.
Most notably, past versions allowed you to pass borrows of uni T
values to
ref
arguments:
type Thing {}
fn example(values: mut Array[ref Thing], thing: ref Thing) {
values.push(thing)
}
type async Main {
fn async main {
let thing = recover Thing()
let vals = []
example(vals, thing)
}
}
Code like this is unsound as it results in an alias to the uni Thing
value
stored in the thing
variable, and thus this is no longer allowed.
Inlining of constants
The compiler is now able to inline constants of type Int
, Float
and Bool
.
In addition, such constants are removed from the executable as there's no point
in keeping them around. This results in better code generation and smaller
executables. Constants of type Array
are not inlined as doing so would
require allocating the Array
at runtime, which would likely have a negative
impact on performance.
Parallel method-local optimizations
The compiler performs various (simple) optimizations on individual methods, and these optimizations don't rely on any shared mutable state. Starting with 0.18.1 these optimizations are performed in parallel to reduce compile times.
The exact impact depends on the allocator in use. When using the glibc allocator there's almost no difference between performing these optimizations in parallel versus performing them sequentially, but when using jemalloc the time spent performing these optimizations is reduced by a factor of up to 2.5 (compared to performing the work sequentially using jemalloc).
Enums use less memory
Each enum has a tag that indicates which constructor is used to create it (also known as a "variant"). The type of this tag used to be a 64 bits integer and would thus require 8 bytes of memory. Starting with 0.18.1, we now use a 16 bits integer such that the tag only needs 2 bytes of space. We are also looking into changing this to use a single byte when possible in the future, further reducing memory usage of enums.
Plans for 0.19.0
For 0.19.0 we plan to work on at least the following:
- Add support for performing HTTP(s) requests (starting with HTTP 1.1 and possibly also HTTP 2),
- Futher improve the JSON pull parsing API
- Implement some form of escape analysis to reduce the amount of heap allocations
- Introduce syntax for
for
loops to make iterating over data easier (in particular when using nested loops) - Make it easier to provide multiple executables in a single project
- Add support for LLVM 19
The exact list is found here but is more of a guideline rather than a list of hard requirements.
Following and supporting Inko
If Inko sounds like an interesting language, consider joining the Discord server or star the project on GitHub. You can also subscribe to the /r/inko subreddit.
Development of Inko is self-funded, but this isn't sustainable. If you'd like to support the development of Inko and can spare $5/month, please become a GitHub sponsor as this allows us to continue working on Inko full-time.