Typed Unions
What are unions
Unions are a special type which can represent one type or another. For instance, you can make a value that either returns a number, or a string. A union can only hold a single value, and therefore represent a single type when used.
Syntax
Unions are declared using pipes:
var x :: int | float;
var y :: string | int | float;
The pipe's only usage in Doxa is to specify a typed union.
Examples
// A union that can be either an integer or a float
var number :: int | float;
number is 42; // Valid - integer
number is 3.14; // Valid - float
// A union that can be a string, integer, or float
var value :: string | int | float;
value is "hello"; // Valid - string
value is 100; // Valid - integer
value is 2.718; // Valid - float
// Unions default to the first type when not initialized
var defaulted :: int | float;
defaulted?; // Prints 0 (default int value)
var float_first :: float | int;
float_first?; // Prints 0.0 (default float value)
Default Behavior
When a union is declared without initialization, it defaults to the first type in the union with its default value:
int
defaults to0
float
defaults to0.0
byte
defaults to0x00
string
defaults to""
tetra
defaults tofalse
nothing
defaults tonothing
This ensures that unions always have a well-defined state and the compiler knows which type is currently active.
Optional Values
A common use case for unions is representing optional values using nothing
:
// Optional integer
var maybe_name :: string | nothing;
maybe_name is "Alice"; // Has a value
maybe_name is nothing; // No value
// Function that might not return a value
function findValue(arr :: MyObject[], target :: string) returns(int | nothing) {
each x in arr {
if (x.name equals target) then {
return x;
}
}
return nothing; // Not found
}
Why unions are useful
Unions are particularly useful when:
- A function can return different types depending on the input
- A variable needs to hold different types at different times
- You want to represent optional values (like
T | nothing
) - You're working with data that can have multiple valid representations
Error Handling
Here is a robust example of how error handling can be done using Doxa. Note that none of this handling is specific to errors, it is a general return pattern which relies on normal values making semanitcs extremely clear and flexible. This relies on the as
keyword which attempts to narrow a value into another type.
enum ErrorList {
Overflow,
Underflow,
}
// Match function to process errors
function handleError(err :: ErrorList) {
match err {
.Overflow => doSomething(),
.Underflow => doSomethingElse(),
};
}
// Function returns a union - we won't know if the value is a number or one of our errors until we check
function addLimit(a :: int, b :: int) returns( int | ErrorList ) {
const result is a + b;
if result > 255 then return ErrorList.Overflow;
if result < 0 then return ErrorList.Underflow;
return result;
}
// All of these return unions
const unknownBigResult is addLimit(1000, 1000); // ErrorList.Overflow
const unknownSmallResult is addLimit(100, -1000); // ErrorList.Underflow
const unknownRightResult is addLimit(100, -10); // 90
// Handle errors - several approaches:
// Approach 1: Pattern matching (cleanest)
match unknownRightResult {
int => onlyUsesInts(unknownRightResult), // unknownRightResult is typed as int here
ErrorList => handleError(unknownRightResult), // unknownRightResult is typed as ErrorList here
}
// Approach 2: Explicit fallback with error handling
const myInt = unknownRightResult as int else {
@panic("This should never happen in production!");
};
onlyUsesInts(myInt);
// Note type checking in control flow doesn't implicitly cast unlike match case which is exhaustive
// this can be done by casting bt it is not as ergonomic as match case
if unknownRightResult @istype int then {
onlyUsesInts(unknownRightResult) as int else{};
} else if unknownRightResult @istype ErrorList then {
handleError(unknownRightResult) as ErrorList else{};
}
Type Casting with as
The as
keyword attempts to cast a union to a specific type. If the cast fails, the else
block is executed:
var value :: int | string;
value is "hello";
// This will execute the else block since value is currently a string
const number = value as int else {
return error.ExpectedInteger;
};
// This will succeed since value is now an int
value is 42;
const doubled = value as int else {
return error.ExpectedInteger;
}; // doubled is 84
Common Patterns
Safe extraction with default:
const result = someUnion as int else {
return 0; // Default value if not an int
};
Error handling:
const number = value as int else {
@panic("This should never happen in production!");
};
Conditional processing:
const processed = value as string else {
// Handle non-string case
return "default";
};
The as
keyword is essential for safely working with unions when you need to extract a specific type.