Skip to content

Type Annotations

As your scripts grow beyond quick one-offs, type annotations become increasingly valuable. They help catch errors early, make your code self-documenting, and keep scripts maintainable as they evolve or get shared with others.

In the Functions section, we briefly introduced type annotations for function parameters and return values. Now let's explore Rad's complete type system - from basic primitives to advanced types like unions, structs, and function signatures.

The Basics

Type annotations let you declare what types of values your function parameters accept and what type of value your function returns. The syntax follows a pattern you may recognize from TypeScript or Python's type hints:

fn calculate_area(width: int, height: int) -> int:
    return width * height

area = calculate_area(5, 10)
print(area)  // 50

Here, width: int and height: int specify that both parameters must be integers, and -> int declares that the function returns an integer.

Why Use Type Annotations?

Type annotations provide three key benefits:

  1. Self-documenting code - The function signature clearly communicates what types it expects and returns
  2. Runtime validation - Rad checks types at runtime and produces helpful error messages when types don't match
  3. Tooling support - IDEs and linters can provide better autocomplete and catch errors before you run your code

Let's see runtime validation in action:

1
2
3
4
5
fn greet(name: str) -> str:
    return "Hello, {name}!"

message = greet(42)  // Error!
print(message)
Error at L4:17

message = greet(42)  // Error!
                ^^ Value '42' (int) is not compatible with expected type 'str'

The error message clearly identifies the problem - we passed an integer when the function expects a string.

Basic Primitive Types

Rad supports the standard primitive types you'd expect: str, int, float, and bool

fn process_data(
    name: str,
    age: int,
    salary: float,
    is_active: bool
) -> str:
    status = is_active ? "active" : "inactive"
    return "{name} ({age}) earns ${salary:.2} - {status}"

result = process_data("Alice", 30, 75000.50, true)
print(result)
Alice (30) earns $75000.50 - active

Special Types: void and null

Two additional types appear throughout Rad but work differently from the primitives:

void: Indicates a function returns nothing. Functions marked -> void don't return a value, and attempting to return a value is an error:

fn log_message(msg: str) -> void:
    print(msg)                 // OK

fn log_message(msg: str) -> void:
    return msg                 // Error: can't return values

null: The single value representing "no value" or "absence." Important: null is only a valid value for optional types marked with ?:

fn get_name() -> str:
    return null          // Error: can't return null from str function

fn get_name() -> str?:
    return null          // OK: str? can return null

Think of null as belonging exclusively to optional types - it's the way to represent "this optional value is absent."

Collection Types

Rad also lets you specify collection types, with their contents being either typed or untyped.

Typed Lists

You can specify what type of values a list contains using the <type>[] syntax:

fn sum_numbers(nums: int[]) -> int:
    total = 0
    for num in nums:
        total += num
    return total

result = sum_numbers([1, 2, 3, 4, 5])
print(result)  // 15

The int[] annotation means "a list of integers". Like with other type annotations, if you try to pass a list containing non-integers, you'll get a runtime error.

More examples with different types:

fn join_words(words: str[]) -> str:
    return words.join(" ")

fn average(numbers: float[]) -> float:
    return sum(numbers) / len(numbers)

sentence = join_words(["Hello", "from", "Rad"])
print(sentence)
Hello from Rad

Typed Maps

Maps can also be typed, specifying both key and value types using { <key type>: <value type> } syntax:

fn count_words(text: str) -> { str: int }:
    words = text.split(" ")
    counts = {}
    for word in words:
        if word in counts:
            counts[word] += 1
        else:
            counts[word] = 1
    return counts

result = count_words("hello world hello")
print(result)
{ "hello": 2, "world": 1 }

The { str: int } annotation means "a map with string keys and integer values".

Generic Collections

When you don't want to specify what's inside a collection, use the generic forms:

fn print_items(items: list) -> void:
    for item in items:
        print(item)

fn lookup(data: map, key: str) -> any:
    return data[key]

Here, list accepts a list with any types of values, and map accepts a map with any keys and values.

The any type means "any type of value" - it's the most permissive type and accepts strings, numbers, booleans, lists, maps, or any other value.

Generic collections are useful when your types are mixed or you don't wish to overcomplicate your type annotations unnecessarily.

Nested Collections

Types can be nested for complex data structures:

fn organize_by_category(items: str[]) -> { str: str[] }:
    categories = {}
    for item in items:
        category = item[0].upper()  // First letter
        if category not in categories:
            categories[category] = []
        categories[category] += [item]
    return categories

items = ["apple", "banana", "apricot", "blueberry"]
result = organize_by_category(items)
print(result)
{ "A": [ "apple", "apricot" ], "B": [ "banana", "blueberry" ] }

The return type { str: str[] } describes a map where each key is a string and each value is a list of strings.

Optional Types

Sometimes a parameter might not always be needed. Rad's optional type syntax with ? makes parameters completely optional - you can pass a value, pass null, or omit the parameter entirely:

fn greet(name: str, title: str?) -> str:
    if title == null:
        return "Hello, {name}!"
    else:
        return "Hello, {title} {name}!"

print(greet("Alice", "Dr."))     // Pass a value
print(greet("Bob", null))        // Explicitly pass null
print(greet("Charlie"))          // Omit the parameter entirely
Hello, Dr. Alice!
Hello, Bob!
Hello, Charlie!

The str? annotation means "an optional string parameter". When omitted or explicitly set to null, the parameter will be null inside the function. This makes it clear that the title parameter is optional and the function knows how to handle its absence.

Optional types work with any type:

fn find_user(id: int, users: map[]) -> map?:
    for user in users:
        if user["id"] == id:
            return user
    return null

users = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
user = find_user(1, users)
print(user)  // {"id": 1, "name": "Alice"}

missing = find_user(999, users)
print(missing)  // null

The map? return type indicates the function might return a map or might return null if no user is found.

Defaults

Parameters can have default values, making them optional to provide when calling the function. This works whether or not the parameter is marked with ?:

fn greet(name: str, greeting: str = "Hello") -> str:
    return "{greeting}, {name}!"

print(greet("Alice"))                // Uses default "Hello"
print(greet("Bob", "Hi"))            // Uses provided "Hi"
Hello, Alice!
Hi, Bob!

The greeting parameter has a default value of "Hello". When you omit it, the default is used. Note that greeting is not marked with ? - it always has a string value, never null.

Defaults & Optionals

When you combine defaults with optional types (?), you can choose whether the default should be null or something else:

fn format_price(amount: float, currency: str? = "USD") -> str:
    if currency == null:
        return "${amount:.2}"
    return "{amount:.2} {currency}"

print(format_price(19.99))           // Uses default "USD"
print(format_price(19.99, "EUR"))    // Uses provided "EUR"
print(format_price(19.99, null))     // Explicitly passes null
19.99 USD
19.99 EUR
$19.99

With str? alone, omitting the parameter means it defaults to null. With str? = "USD", you can provide a non-null default value, but callers can still explicitly pass null if they want.

Union Types

Sometimes a function can accept or return multiple different types. Union types express this with the | operator:

fn to_string(val: int|float|str) -> str:
    return str(val)

print(to_string(42))
print(to_string(3.14))
print(to_string("hello"))
42
3.14
hello

The int|float|str annotation means "accepts an int, float, or string" - any of these three types is valid.

Error Union Types

A common union pattern in Rad is combining error with another type to represent operations that might fail:

fn divide(a: float, b: float) -> float|error:
    if b == 0:
        return error("Cannot divide by zero")
    return a / b

result = divide(10, 2)
print(result)  // 5

The float|error return type signals that this function returns either a float (on success) or an error value (on failure).

Error Handling in Rad

Rad has a comprehensive error handling model. We'll cover error handling in detail in a later section: Error Handling.

Advanced Types

Rad's type system includes several advanced features for expressing complex data structures and constraints.

Enum Types

When a value should be restricted to specific strings, use enum types:

fn set_log_level(level: ["debug", "info", "warn", "error"]) -> str:
    return "Log level set to: {level}"

print(set_log_level("info"))
// set_log_level("trace")  // Error: "trace" not in enum
Log level set to: info

The ["debug", "info", "warn", "error"] annotation restricts the parameter to exactly these four string values. Any other string will cause a runtime type error.

This is particularly useful for configuration options, status values, and other cases where only certain strings are valid:

fn create_connection(
    host: str,
    protocol: ["http", "https", "ws", "wss"] = "https"
) -> str:
    return "{protocol}://{host}"

url = create_connection("api.example.com")
print(url)
https://api.example.com

Structured Maps

For maps with specific named fields, use the struct syntax with quoted keys:

fn create_user(name: str, age: int, email: str) ->
        { "name": str, "age": int, "email": str, "id": int }:
    return {
        "name": name,
        "age": age,
        "email": email,
        "id": rand_int(1000, 9999)
    }

user = create_user("Alice", 30, "alice@example.com")
print(user)
{ "name": "Alice", "age": 30, "email": "alice@example.com", "id": 7234 }

The { "name": str, "age": int, "email": str, "id": int } annotation describes a map with exactly these four fields, each with a specific type. Notice the quoted keys - this distinguishes named fields from the typed map syntax we saw earlier.

Optional Fields in Structs

Fields can be marked as optional with ?:

fn get_user_profile(id: int) ->
        { "name": str, "age": int, "bio"?: str, "avatar"?: str }:
    // Fetch user... in this example, we'll return mock data
    return {
        "name": "Bob",
        "age": 25,
        "bio": "Software engineer"
        // avatar field is omitted
    }

profile = get_user_profile(123)
print(profile)
{ "name": "Bob", "age": 25, "bio": "Software engineer" }

The "bio"?: str and "avatar"?: str fields are optional - the map might or might not contain them.

Nested Structures

Struct types can be nested for complex data:

fn fetch_article() -> {
    "title": str,
    "author": { "name": str, "id": int },
    "metadata": { "views": int, "likes": int },
}:
    return {
        "title": "Getting Started with Rad",
        "author": {"name": "Alice", "id": 1},
        "metadata": {"views": 1234, "likes": 56}
    }

article = fetch_article()
print("Article: {article.title} by {article.author.name}")
print("Stats: {article.metadata.views} views, {article.metadata.likes} likes")
Article: Getting Started with Rad by Alice
Stats: 1234 views, 56 likes

Function Types

Functions themselves can be typed, which is especially useful when passing functions as parameters:

fn apply_to_list(items: str[], transform: fn(str) -> str) -> str[]:
    result = []
    for item in items:
        result += [transform(item)]
    return result

words = ["hello", "world"]
upper_words = apply_to_list(words, upper)
print(upper_words)
[ "HELLO", "WORLD" ]

The fn(str) -> str annotation describes a function that takes a string parameter and returns a string.

Other examples of valid function type annotations:

fn() -> int
fn(str, str) -> str
fn(str[]) -> void

Variadic and Named Parameters

Type annotations work seamlessly with Rad's parameter patterns, as seen earlier in Functions.

Variadic Parameters

When a function accepts unlimited arguments, you can type the variadic parameter:

fn sum_all(*numbers: int) -> int:
    total = 0
    for num in numbers:
        total += num
    return total

result = sum_all(1, 2, 3, 4, 5)
print(result)
15

The *numbers: int annotation means "zero or more integer arguments". All arguments passed to this variadic parameter must be integers.

Named-Only Parameters

Named-only parameters (those after *) can also be typed:

fn format_text(
    text: str,
    *,
    uppercase: bool = false,
    prefix: str = "",
    suffix: str = ""
) -> str:
    result = prefix + text + suffix
    return uppercase ? upper(result) : result

output = format_text("hello", uppercase=true, prefix=">>> ")
print(output)
>>> HELLO

Combining Everything

Here's a function that combines positional, variadic, and named-only parameters with types:

fn create_report(
    title: str,
    *data_points: int|float,
    *,
    format: ["text", "html", "json"] = "text",
    include_summary: bool = true
) -> str:
    total = sum(data_points)
    avg = total / len(data_points)

    report = "=== {title} ===\n"
    report += "Data: {data_points.join(', ')}\n"

    if include_summary:
        report += "Total: {total}, Average: {avg:.2}"

    return report

output = create_report(
    "Q4 Sales",
    100, 150, 200, 175,
    format="text",
    include_summary=true
)
print(output)
=== Q4 Sales ===
Data: 100, 150, 200, 175
Total: 625, Average: 156.25

This example demonstrates:

  • A required positional parameter (title: str)
  • A typed variadic parameter accepting multiple numeric values (*data_points: int|float)
  • Named-only parameters with enum and boolean types
  • A clear, self-documenting function signature

Summary

Type annotations are an optional but powerful tool for keeping your Rad scripts maintainable and self-documenting, especially as they grow in complexity or get reused across projects.

Key takeaways:

  • Syntax: Parameter types use param: <type>, return types use -> <type>
  • Benefits: Self-documenting code, runtime validation, better tooling support
  • Primitive types: str, int, float, bool for basic values
  • Special types: void (function returns nothing), null (only valid for optional types marked with ?)
  • Collection types:
    • T[] for typed lists (e.g., str[], int[], float[])
    • { <key type>: <value type> } for typed maps (e.g., { str: int })
    • list and map for generic collections (any contents)
    • Nested collections like int[][] and { str: str[] }
  • Optional types: T? for nullable values (e.g., str?, int?)
  • Union types: T|U for multiple acceptable types (e.g., int|float, str|list)
  • Advanced types:
    • Enums: ["value1", "value2", "value3"] for restricted string values
    • Structs: { "field1": type1, "field2"?: type2 } for structured maps with named fields (quoted keys) and optional fields
    • Function types: fn(<param_type>) -> <return_type> for function parameters and variables
    • Nested structures: Complex combinations of the above
  • Special parameters: Work with variadic (*param: <type>) and named-only parameters

Type annotations make your code clearer to both humans and tools, catching errors early and making your intentions explicit.

Next

We've briefly seen error|T union types in this section - functions that return either a value or an error.

In the next section, we'll explore Rad's comprehensive error handling model in depth: Error Handling.