Skip to content

Instantly share code, notes, and snippets.

@urandom
Last active March 31, 2021 13:44
Show Gist options
  • Save urandom/b21f39792e963c935970cb43a2ac9021 to your computer and use it in GitHub Desktop.
Save urandom/b21f39792e963c935970cb43a2ac9021 to your computer and use it in GitHub Desktop.
null safety

Go supports nil values for certain classes of types, namely: pointers, functions, interfaces, maps, slices and channels. Nil values of these types indicate an absence of value, and vary in usability. Slices and channels are quite usable as nil values, maps less so. For pointers, interfaces and functions, besides indicating an absence, the nil value has little other usability and usually results in a panic if not checked beforehand.

And while the nil value has usability, sometimes a developer want one of the abovementioned type classes to always be non-nil. Unfortunately, Go currently does not provide an expression for such a use case.

The document explores two possible ways to provide such an expressibility, by investigating various languages that have either been designed from the ground up to support such constructs, or they have been added after the fact.

Optional-based

Some languages allow the developer to speficy whether a value is there or not by means of an Optional or similar object. Such an object can either hold a value of a certain type, or nothing. Such objects have a means to be checked whether they have something or nothing. Some implementations will also allow the user to either change a present value to something else (including changing the type), or specify a value to be used in case there is no value in the object.

Java has an Optional class that allows the user to check whether a value is present, and also change it or specify a different value, if the one in the instance is not present.

Rust has an Option enum which allows more or less the same operations as Java. The difference is that Rust doesn't have a null value to begin with, so Option is the only way to provide nothing.

Swift also provides an Optional type. Optional types are the only types that can contain a nil value. Instead of methods however, it uses operators to achieve a similar result as the methods that are usually present in other languages. It has a ?? "Nil-coalescing operator", which is similar to Rust's or_else method. It can bind the existing value in an Optional using language constructs such as if let, similar to Java Optional's ifPresent method, or the combination of isPresent and get. Finally, one invoke a method/property of the underlying type using the ?. operator, which results in an Optional value.

Possible implementation in Go

With a generics-enabled go 1.18, an Optional type may be provided by Go's standard library. It might look something like this:

type Optional[T any] struct {
	p *T
}

func (o Optional[T]) Val() T {
	if o.p != nil {
		return *o.p
	}
	var zero T
	return zero
}

func (o Optional[T]) OrElse(other T) T

This would be the most reasonable implementation, as it requires no change in the langauge specification. However, methods like Map or FlatMap would be impossible to implement with the current generics proposal.

More unlikely, the Optional type could be implemented as a builtin instead. Like other builtins, it would be available in the universe block, and will not need to be imported. The impossible Map and FlatMap methods could then be implemented, though that is less likely.

A more outlandish tought is to treat any nil-able value in Go as if it were an Optional. Rather than having a separate type, any nil-able type would have a set of methods that would facilitate its use as an Optional. Methods like OrElse, Map, FlatMap may be added. This however would not in any way help with actual null safety, since the types can continue to be consumed as-is.

Nullable and non-nullable types

Some languages differentiate between nullable and non-nullable types. By default, types are non-nullable, but nullable types can be specified, usually by adding a symbol to the type: int?. There are usually additional language constructs and operators, to facilitate easier handling of nullable types.

Kotlin's nullable values cannot be used as is, unless the developer first checks that the value is non-null, and does not mutate it afterwards. Its compiler tracks the usage of the value to make sure the above conditions are true. It also allows safe calls on nullable types using the ?. oeprator, produces a nullable results, or a null if the value on the left is null. Another null-oriented operator is ?:, which receives a non-null value as it's right operand, and returns it if the left value is null.

Swift may also be considered to fall into this category as well. Even though it uses an Optional type to specify a nullable type, it uses operators and language constructs to work with it. It also uses ? as a shorthand to specify a Optional type: Int?.

Zig also provides optional types by prefixing a ? in front of a regular type: ?int. Like Swift, it uses special constructs along with these optional types. The orelse expression evaluates the right operand if the left one is null: const ptr = malloc(1234) orelse return null;. To convert an optional type to a non-null one, a special if construct is used to create a non-null variable from an optional one:

if (optional_foo) |foo| {
      doSomethingWithFoo(foo);
}

Dart's now has nullable and non-nullable types as well. Following the pattern, it uses the ? symbol to differentiate nullable types from non-null ones: String?. It uses the ?? operator for converting a nullable value to a non null one, with the right operand being used when the left one is null. The null-safe member access operator ?. can also be used to access methods and fields of a nullable type safely - in order to produce a nullable value.

C# is another language which adds nullable and non-null types. This feature can be enabled per-file and project. When enabled, the ? symbol specifies that a type is nullable: string?. It has a "Null-coalescing operator" ?? for converting nullable to non-nullable values, similarly to the previously mentioned languages. It also has "Null-conditional operators" ?. and ?[] for accessing methods, fields and indexes of nullable values safely.

Possible implementation in Go

It is too late to make types non-null by default as in other languages. Examples where such were retroactively added require additional configuration and/or annotations in order to work and continue to be backwards compatible. Such an approach is inapplicable in Go.

Instead, the reverse could potentially be implemented. Have types that may never be nil. For the sake of argument, lets say that any type that has the ! symbol added to it would mean that it can never be nil, nor can a nil value be assigned to it. Examples would be:

// reads as non-null pointer of Client
func (c !*Client) Send(ctx !context.Context, u !*net.URL, parser !func(io.Reader)) (*Data, error) {
	r, err := http.NewRequest(c.methodForURL(u), u.String(), nil)
	if r == nil {
		return nil, err
	}
	
	r = r.Context(ctx)
	
	resp, err := c.httpClient.Do(r)
	if resp == nil {
		return nil, err
	}
	defer resp.Body.Close()
	
	data := parser(resp.Body)
	
	return data, nil
}

A non-nil type value will always be assignable to the standard nullable type:

	var foo !*Foo = &Foo{}
	
	var foo2 Foo = foo

This will allow the functions make and new, as well as pointer literals to be changed in a backwards compatible way to return non-nil types.

Converting nilable types to non-nil ones will require some more language support however. One solution is to have the Go compiler track variable usages as in Kotlin, so that after a negative nil check (and no further mutations of the variable with more nilable values), such nilable value can be assignable to non-nil ones. In an essence, this means that the compiler keeps track of whether a nil-able value has been checked for nil by the developer at some point. After the check, that value can then be assigned to a non-nil one without a compile error. If the value is mutated so that an expression that produces a nilable value is assigned to it after the nil check (E.g. by assigning the result of a function that returns a nilable value), it would need to be checked again before it can be assigned to a non-nil value without a compile error. Such a compiler-driven solution might look like this:

func F(p *Data) {
	/* The following commented-out block will produce a compile-time error
	G(p)
	*/

	if p == nil {
		return
	}
	
	G(p)
}

func G(p !*Data)

Another solution is to add a "Nil-coalescing operator" ??, similarly to other languages in this category. One advantage some of the other languages in this category have is the usage of return expressions. In kotlin, one might write:

val s = person.name ?: return

This will not be possible in Go, since return is a special statement. For ?? to be useful, it's right hand operand should be allowed to be a block with a return statement, as well as an expression. This approach would allow the following:

f, err := os.Open("path")

f = f ?? openBackupFile()

...

f, err := os.Open("path")
f = f ?? {
	return err
}

A drawback of this approach would be the repetition of variable = variable ?? {}, although that could be mitigated with a similar operator to C#s ??=

A third approach would be to expand the if statement grammar, somewhat like Swift or Zig. If the expression block checks whether a variable is not nil, then in the if-branch block that same variable will be a non-nil type:

f, err := os.Open("path")
if f != nil {
	// f now has a type of !*os.File
}

The addition of a ?. operator similar to other languages in this category would be a welcome addition, but is not as important.

One interesting consideration is the current workflow of consuming data from functions that also return errors. The current Go workflow is more or less:

f, err := os.Open("path")
if err != nil {
	// Do something with the error, possibly return
}

// use f
io.ReadAll(f)

The unspoken rule is that if err is nil, then whatever nilable value was return must not be nil. The language however has no way of actually guaranteeing that. In fact, something like the following code block would actually be more correct, even in current versions of Go:

f, err := os.Open("path")
if f == nil {
	// f is nil, Why?
	
	// We could assume that err is not nil, and just return it
	return err
	
	// Or
	if err != nil {
		return nil
	} else {
		return errors.New("file is nil")
	}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment