Common programming concepts

Chapter 3 · Variables, types, functions, and control flow.

Reading perspective

Before sending a conversation to a model, estimate its size and decide whether to split it into smaller pieces. Use variables to store the counts, functions to calculate costs, and conditions to choose a model.

The examples use rough token estimates and sample prices and context limits. Use your model's tokenizer and provider's pricing for actual usage.

Run the examples

Install Canary using Get started, then create a project:

baml new book-basics
cd book-basics

Put the examples in baml_src/main.baml. Each section gives you a separate example; replace the previous code unless the section says to keep it. Most examples define main, which you can check and run with:

baml check
baml run main

check finds errors without running the program. run executes a function and prints its return value. You won't need an API key for the commands in this chapter.

Variables and mutability

A variable gives a value a name. Use let to create a variable, and = to give it its initial value:

BAMLmain.baml
function main() -> int {
    let remaining = 8_000;
    log.info(remaining);

    remaining = remaining - 512;
    remaining = remaining - 3_100;
    log.info(remaining);

    remaining
}
function main() -> int {
    let remaining = 8_000;
    log.info(remaining);

    remaining = remaining - 512;
    remaining = remaining - 3_100;
    log.info(remaining);

    remaining
}

main is a function: a named piece of code you can run. Its body is the code between { and }. The -> int says that it returns an integer. Here, the final expression, remaining, supplies that value.

Read the body from top to bottom:

  • let remaining = 8_000; starts with a budget of 8,000 tokens. Underscores make large numbers easier to read; they don't change the value.
  • remaining = remaining - 512; subtracts 512 and stores the result under the same name.
  • The next assignment subtracts another 3,100. The function returns 4388.

Changing an existing variable's value is called reassignment. BAML's let allows reassignment without an extra keyword.

Inspect a value

The two log.info calls record remaining before and after the subtractions. Enable those logs when you run the function:

baml run main --log info
[INFO] 8000
[INFO] 4388
4388

The last line is the return value. Without --log info, this command prints only that value.

Keep the same type when reassigning

A type describes what kind of value a variable can hold. BAML infers int for remaining from its initial value. Reassignment can change the number, but it can't replace it with text:

BAMLmain.baml
function main() -> int {
    let remaining = 8_000;
    remaining = "spent";
    remaining
}
function main() -> int {
    let remaining = 8_000;
    remaining = "spent";
    remaining
}

This example intentionally fails baml check: remaining requires an int, and "spent" is a string. Keep the budget as a number; use a separate value when you need a message.

A let declaration also needs an initial value. let remaining; doesn't compile.

Reuse a name for a new value

A second let creates a new variable. It can reuse a name and have a different type:

BAMLmain.baml
function main() -> int {
    let transcript = "  What is your refund policy?  ";
    let transcript = transcript.trim();
    let transcript = transcript.length();
    transcript
}
function main() -> int {
    let transcript = "  What is your refund policy?  ";
    let transcript = transcript.trim();
    let transcript = transcript.length();
    transcript
}

Each line uses the previous value of transcript to calculate the next one. trim() removes surrounding whitespace; length() counts the characters. The final transcript is an int, and main returns 27.

This is called shadowing. Unlike reassignment, it creates a new binding between a name and a value. Names declared inside a block are available within that block; they don't become available outside it.

Data types

Start with these four types:

TypeHoldsExample
intWhole numbers8_000
floatNumbers with a fractional part2.85
stringText"Order status?"
boolA true or false valuetrue

BAML can infer a variable's type, or you can write a type annotation after its name: let remaining: int = 8_000;. An annotation asks the compiler to check that the value has that type.

Integer division drops the fractional part

Suppose a call uses 950,000 input tokens at an invented price of 300 cents per million tokens:

BAMLmain.baml
function main() -> string {
    let tokens = 950_000;
    let price_cents_per_mtok = 300;

    let cents = tokens * price_cents_per_mtok / 1_000_000;
    let dollars = cents / 100;

    `this call will cost about $${dollars}`
}
function main() -> string {
    let tokens = 950_000;
    let price_cents_per_mtok = 300;

    let cents = tokens * price_cents_per_mtok / 1_000_000;
    let dollars = cents / 100;

    `this call will cost about $${dollars}`
}

The calculation produces 285 cents, but the function returns "this call will cost about $2". It compiles successfully and still gives the wrong display value.

Both operands in cents / 100 are integers. Integer division discards the fractional part, so 285 / 100 is 2. The remainder operator, %, gives the part left over: 285 % 100 is 85.

Use a float operand for fractional division

Change the divisor to 100.0:

BAMLmain.baml
function main() -> string {
    let tokens = 950_000;
    let price_cents_per_mtok = 300;

    let cents = tokens * price_cents_per_mtok / 1_000_000;
    let dollars = cents / 100.0;

    `this call will cost about $${dollars}`
}
function main() -> string {
    let tokens = 950_000;
    let price_cents_per_mtok = 300;

    let cents = tokens * price_cents_per_mtok / 1_000_000;
    let dollars = cents / 100.0;

    `this call will cost about $${dollars}`
}

Now dollars is a float, and the function returns "this call will cost about $2.85". Arithmetic with a float operand produces a float result.

A type annotation doesn't perform this conversion for you:

BAMLmain.baml
function main() -> float {
    let cents = 285;
    let dollars: float = cents / 100;
    dollars
}
function main() -> float {
    let cents = 285;
    let dollars: float = cents / 100;
    dollars
}

The compiler rejects the int result of cents / 100 where you requested float. Change the calculation to cents / 100.0 to fix it.

Floats approximate many decimal values: 0.1 + 0.2 produces 0.30000000000000004. Keep money in integer units when you need exact accounting. The examples here demonstrate arithmetic, not a billing implementation.

Compare values

Comparisons such as remaining > 0 and remaining <= 8_000 produce a bool. Use == to test equality and != to test inequality. Combine boolean conditions with && (both are true), || (at least one is true), and ! (negation).

Use matching numeric types when comparing numbers. Mixed arithmetic works, but ordering an int against a float, such as 2 < 2.5, doesn't compile. Equality doesn't convert them either: 2 == 2.0 is false.

An int holds whole numbers from −2⁶² to 2⁶²−1. Arithmetic overflow and division by zero cause runtime panics. Handle errors explains how panics differ from ordinary errors.

Strings

Use double quotes for literal text and backticks when you want to insert values. The ${expression} inside a backtick string is called interpolation:

BAMLmain.baml
function describe_cost(cents: int) -> string {
    `cost in cents: ${cents}`
}
function describe_cost(cents: int) -> string {
    `cost in cents: ${cents}`
}

describe_cost(285) returns "cost in cents: 285". The double-quoted "cost in cents: ${cents}" would keep ${cents} as literal text.

Use \n for a line break. Backtick strings can also span several source lines, which is useful when writing prompts.

+ joins two strings, but it doesn't convert a number to text. Use interpolation when the value you want to insert is a number or another type.

Functions

A function lets you name a calculation and use it with different inputs. Here is a rough token estimator:

BAMLmain.baml
Default; override by name: chars_per_token: int = 4. Returned to the caller: text.trim().length() / chars_per_tokenDefault; override by name: chars_per_token: int = 4. Returned to the caller: text.trim().length() / chars_per_token
  • text and chars_per_token are parameters: names for the inputs inside the function.
  • Each parameter declares its type after :.
  • -> int declares the return type. Every function declares one.
  • chars_per_token: int = 4 supplies a default value when the caller leaves that input out.

The expression text.trim().length() / chars_per_token supplies the return value. It uses integer division, so this estimator rounds down. It isn't a model tokenizer.

Keep estimate_tokens and add a caller:

BAMLmain.baml
function main() -> string {
    let transcript = "user: Where is my order?\nagent: It shipped on Tuesday.";
    let prose = estimate_tokens(transcript);
    let dense = estimate_tokens(transcript, chars_per_token = 3);
    `between ${prose} and ${dense} tokens`
}
function main() -> string {
    let transcript = "user: Where is my order?\nagent: It shipped on Tuesday.";
    let prose = estimate_tokens(transcript);
    let dense = estimate_tokens(transcript, chars_per_token = 3);
    `between ${prose} and ${dense} tokens`
}

An argument is a value supplied in a call. The first call passes only transcript. The second also passes chars_per_token = 3. Running main returns "between 13 and 18 tokens".

Override defaults by name

To override a defaulted parameter, write its name. estimate_tokens(transcript, 3) doesn't compile; use estimate_tokens(transcript, chars_per_token = 3).

You can name required arguments too. This call names both arguments and puts them in a different order:

baml run -e 'estimate_tokens(chars_per_token = 3, text = "abcdefghi")'

It returns 3. The -e option evaluates an expression using the functions in your project.

Use snake_case for function and variable names: lowercase words separated by underscores.

Return values

A function can return its final expression, as estimate_tokens does, or use return to exit earlier:

BAMLmain.baml
function clamp_tokens(tokens: int, window: int) -> int {
    if (tokens > window) {
        return window;
    }
    tokens
}
function clamp_tokens(tokens: int, window: int) -> int {
    if (tokens > window) {
        return window;
    }
    tokens
}

clamp_tokens(9_000, 8_000) returns 8000 from inside the if. clamp_tokens(500, 8_000) reaches the final expression and returns 500. Both results must match -> int.

A function that performs work without returning a value declares -> void. A bare return; exits that function:

BAMLmain.baml
function report_budget(tokens: int, window: int) -> void {
    if (tokens <= window) {
        return;
    }
    log.info(`over budget by ${tokens - window} tokens`);
}
function report_budget(tokens: int, window: int) -> void {
    if (tokens <= window) {
        return;
    }
    log.info(`over budget by ${tokens - window} tokens`);
}

report_budget logs only when tokens exceeds window. The next sections explain if in more detail.

AI functions

An AI function has typed parameters and a return type too. Its body specifies a client and a prompt:

BAMLmain.baml
client Fast = openai.ResponsesClient.new(model = "gpt-4o-mini", api_key = env.OPENAI_API_KEY);

function summarize(transcript: string) -> string {
    client: Fast
    prompt: `${role("system")}Summarize this conversation in one sentence.
${role("user")}${transcript}`
}
client Fast = openai.ResponsesClient.new(model = "gpt-4o-mini", api_key = env.OPENAI_API_KEY);

function summarize(transcript: string) -> string {
    client: Fast
    prompt: `${role("system")}Summarize this conversation in one sentence.
${role("user")}${transcript}`
}

Fast configures an OpenAI client. role("system") starts the message containing the instructions; role("user") starts the message containing the transcript. Everything after a role marker belongs to that message until the next marker.

Calling summarize(transcript) asks the model for a string. That call requires provider credentials and network access. You can check this declaration with baml check without making a model request.

Parse a sample response without a model

BAML also generates operations for the function. @parse converts supplied response text to its declared return type:

baml run -e 'summarize@parse("The order shipped Tuesday.")'

This returns "The order shipped Tuesday." without calling the provider. To inspect the prompt with a transcript inserted, run:

baml run -e 'summarize@render_prompt("Where is my order?")'

These operations let you inspect the prompt and test response parsing separately from making a live call. Images and vision models shows model calls and structured return values in a complete example.

Comments

Comments explain decisions to the next person reading your code. // starts a comment that ends at the end of the line. /* ... */ marks a block comment; block comments don't nest.

Use /// above a declaration to document it. Inside a function, //# names a step:

BAMLmain.baml
/// Estimates token usage from character count.
/// This is a rough estimate, not a model-specific tokenizer.
function estimate_tokens(text: string, chars_per_token: int = 4) -> int {
    //# Count the characters
    let chars = text.trim().length();
    //# Divide by the average token width
    chars / chars_per_token
}
/// Estimates token usage from character count.
/// This is a rough estimate, not a model-specific tokenizer.
function estimate_tokens(text: string, chars_per_token: int = 4) -> int {
    //# Count the characters
    let chars = text.trim().length();
    //# Divide by the average token width
    chars / chars_per_token
}

The doc comment explains the estimator's limitation. The step headers name the two parts of the calculation. Neither changes the returned value.

Inspect the function and its documentation with:

baml describe estimate_tokens

Use /// for declaration documentation; an ordinary // or /** ... */ comment doesn't serve that purpose.

Choose a value with if

Use if to choose which code runs. An if can also produce a value that you assign to a variable:

BAMLmain.baml
The selected branch supplies model: if (tokens <= 8_000) {
        "mini"
    } else if (tokens <= 100_000) {
        "standard"
    } else {
        "long-context"
    }The selected branch supplies model: if (tokens <= 8_000) {
        "mini"
    } else if (tokens <= 100_000) {
        "standard"
    } else {
        "long-context"
    }

The conditions are checked in order. The first matching branch supplies the value of model; the final else handles inputs that didn't match either condition. The strings are labels for this example, not provider configurations.

baml run -e 'route_for(12_400)'

The result is "route to standard".

When you use an if for its value, include an else so every path supplies one. An if used only to perform an action, such as the early return in clamp_tokens, doesn't need an else.

BAML uses this same if syntax wherever you need a conditional value. There is no separate condition ? yes : no operator.

Repeat work with loops

Visit each item with for

An array stores several values in order. This example uses string[], an array of strings, to represent the messages in a conversation:

BAMLmain.baml
function estimate_tokens(text: string, chars_per_token: int = 4) -> int {
    text.trim().length() / chars_per_token
}

function total_tokens(messages: string[]) -> int {
    let total = 0;
    for (let message in messages) {
        if (message.trim() == "") {
            continue;
        }
        total = total + estimate_tokens(message);
    }
    total
}

function main() -> int {
    total_tokens(["Order status?", "", "It shipped Tuesday."])
}
function estimate_tokens(text: string, chars_per_token: int = 4) -> int {
    text.trim().length() / chars_per_token
}

function total_tokens(messages: string[]) -> int {
    let total = 0;
    for (let message in messages) {
        if (message.trim() == "") {
            continue;
        }
        total = total + estimate_tokens(message);
    }
    total
}

function main() -> int {
    total_tokens(["Order status?", "", "It shipped Tuesday."])
}

for (let message in messages) runs the body once for each message. continue skips the rest of the current iteration, so blank messages don't contribute to total.

The two nonempty messages contribute 3 and 4 under this estimator. Running main returns 7. An empty array returns 0 because the loop body never runs.

Repeat while a condition holds

Use while when you want to repeat an operation until a condition becomes false. For example, count how many fixed-size chunks a token estimate needs:

BAMLmain.baml
function count_chunks(tokens: int, window: int) -> int {
    let remaining = tokens;

    let chunks = 0;
    while (remaining > 0) {
        remaining = remaining - window;
        chunks = chunks + 1;
    }
    chunks
}
function count_chunks(tokens: int, window: int) -> int {
    let remaining = tokens;

    let chunks = 0;
    while (remaining > 0) {
        remaining = remaining - window;
        chunks = chunks + 1;
    }
    chunks
}
baml run -e 'count_chunks(26_000, 8_000)'

It returns 4: three full chunks and one partial chunk. This counts chunks; it doesn't split the text. Pass a positive window so each iteration reduces remaining.

break exits a loop immediately. continue skips to the next iteration. Loops perform work without producing a value of their own; these examples store the result in a variable and return it after the loop.

Try it

Use count_chunks to check the boundaries:

  • count_chunks(0, 8_000) should return 0.
  • count_chunks(8_000, 8_000) should return 1.
  • count_chunks(8_001, 8_000) should return 2.

Then change the function to return early when tokens is 0. The three results should stay the same. This gives you a small check that your change preserved the behavior.