Skip to content
📖 The Auto Programming LanguageBook contents

Smart Casts & Flow Typing ​

In Chapter 7, we learned about enums and pattern matching with is. In Chapter 13, we explored generics and specs. These features give Auto a powerful type system, but there is a missing piece: how do you work with a value when you know it is one specific type out of several possibilities?

Enter smart casts and flow typing -- the compiler's ability to narrow a value's type based on control flow. If you check if value is int, the compiler knows that inside that branch, value is an int. You do not need a cast. You do not need a type annotation. The compiler figures it out for you.

In this chapter, we will learn how Auto's flow-sensitive type system works, how union types let you express "one of several types," and how smart casts eliminate boilerplate while keeping your code safe.

What Are Smart Casts? ​

A smart cast is the compiler's ability to automatically narrow a value's type within a specific branch of control flow. After a type check, the compiler remembers the result and treats the value as the narrowed type -- no explicit cast required.

Many languages have this concept. Kotlin calls them "smart casts." TypeScript has "type narrowing." C# has "pattern-based matching." Scala 3 has "match types." Auto takes the same idea and integrates it with the is keyword, union types, and the is expression for pattern matching.

The key insight is simple: if you check if value is T, then inside that branch, value is T. The compiler trusts your check and narrows the type accordingly.

Why does this matter? Three reasons:

  1. Safety -- You never accidentally use a value as the wrong type. The compiler enforces that each branch only sees the type it checked for.

  2. Convenience -- You do not need to write explicit casts, as operators, or unwrap() calls. The compiler does the work for you.

  3. Exhaustiveness -- The compiler can verify that you handle every possible type. If you miss one, it tells you at compile time, not at runtime.

if x is T Narrowing ​

The simplest form of smart cast is if value is T. This checks whether value is of type T and narrows the type within the branch.

Smart cast with `if value is T`

This prints:

Integer: 42
String: hello

Let us break down what is happening:

  1. fn describe(value int | String) -- The parameter value has type int | String, which is a union type. It means "value is either an int or a String." We will explore union types in the next section.

  2. if value is int -- This checks whether value is an int. If it is, the compiler narrows value's type to int within the if branch.

  3. print("Integer: " + value) -- Inside the if branch, value is known to be an int. The + operator works because int supports concatenation with String.

  4. else -- In the else branch, the compiler narrows value's type to String (the only remaining type in the union).

  5. print("String: " + value) -- Inside the else branch, value is known to be a String. String concatenation works directly.

The smart cast eliminates the need for explicit casting. In many languages, you would need to write something like (value as int) or value.asInstanceOf[Int] or dynamic_cast<int>(value). In Auto, the is check is sufficient.

In Python, the equivalent is isinstance(value, int). In TypeScript, the equivalent is typeof value === "number". In C, you need a tagged union with a manual discriminant check. In Rust, you would use match on an enum. Auto's is check is the most concise of all.

Union Types ​

Union types are the foundation of smart casts. A union type T | U means "a value of type T or type U." You can chain unions: T | U | V means "one of T, U, or V."

Union types in Auto serve the same role as sum types in type theory. They let you express that a value can be one of several different things, and the compiler will ensure you handle each possibility.

Union types with three alternatives

This prints:

42
3.14
hello

Several things to notice:

  1. int | float | String -- A three-way union type. The | operator combines types. You can have as many alternatives as you need.

  2. print(value) -- The print function accepts any value that implements display formatting. Since int, float, and String all support printing, this works without narrowing. The compiler resolves the call through the shared interface.

  3. process(42) -- Auto infers that 42 is an int, which satisfies the int | float | String union. No annotation needed.

  4. process(3.14) -- Similarly, 3.14 is inferred as float, which is part of the union.

  5. process("hello") -- A string literal satisfies the String part of the union.

Union types are Auto's answer to the question "what type should this parameter be?" Instead of using a base class or an interface, you simply list the allowed types. The compiler enforces that only values of those types are passed.

In TypeScript, union types work the same way: number | string. In Python, you would use Union[int, float, str] from typing. In Rust, you would define an enum with variants for each type. In C, you would use a tagged union. Auto's T | U syntax is the most direct expression of this idea.

Exhaustive Narrowing ​

When a union type has a finite set of alternatives, the compiler can check that you handle every case. This is called exhaustive matching, and it applies to both if/else chains and is expressions.

Enums (from Chapter 7) are a natural fit for exhaustive narrowing. Each variant is a distinct type, and the compiler knows the complete set of variants.

Exhaustive narrowing with enum pattern matching

This prints:

78.53975

Key points:

  1. enum Shape -- Defines three variants, each carrying different data. Circle has one float (radius). Rect has two floats (width and height). Triangle has three floats (side lengths).

  2. is shape { Circle(r) => ..., Rect(w, h) => ..., Triangle(a, b, c) => ... } -- The is expression performs exhaustive pattern matching. Each variant is destructured, and the inner values are bound to local variables.

  3. Circle(r) => 3.14159 * r * r -- Inside this branch, r is a float (narrowed from the Circle(float) variant). No cast needed.

  4. Rect(w, h) => w * h -- Similarly, w and h are narrowed to float within this branch.

  5. Triangle(a, b, c) => 0.0 -- The triangle case is simplified here. In a real implementation, you would use Heron's formula: sqrt(s * (s-a) * (s-b) * (s-c)) where s = (a + b + c) / 2.

  6. Shape.Circle(5.0) -- Enum variant construction uses dot notation, not :: (from Chapter 7).

The exhaustiveness guarantee is powerful. If you add a new variant to Shape -- say, Square(float) -- the compiler will flag area as non-exhaustive. You cannot forget to handle a case.

In Rust, this is the same match expression. In Python 3.10+, this is the match/case statement. In C, this is switch with a tagged union. In TypeScript, you would use a discriminated union with switch. Auto's is expression is the most natural syntax for this pattern.

Smart Casts in Conditionals ​

Smart casts are not limited to simple type checks. They work with method calls, nested conditions, and any flow-sensitive context. After a successful is check, the narrowed type persists for the rest of the branch.

Smart cast enabling type-specific method calls

This prints:

HELLO
Not a string

The key observation here:

  1. v.to_upper() -- This method call only makes sense for String values. But v is declared as int | String | float. How can we call .to_upper() on it?

  2. The smart cast -- After if v is String, the compiler narrows v to String within the if branch. Now .to_upper() is a valid call. The compiler knows v is a String, so it allows String methods.

  3. else -- In the else branch, v is narrowed to int | float (everything that is not String). Neither int nor float has a .to_upper() method, so the code returns a fallback string instead.

This is where smart casts become essential. Without them, you would need to explicitly cast v to String before calling .to_upper(). With smart casts, the compiler does the narrowing automatically based on the is check.

In TypeScript, this is called "type narrowing" and works with typeof checks and type guards. In Kotlin, this is the "smart cast" feature. In Python, isinstance does not provide static narrowing (mypy can infer it, but the runtime does not enforce it). Auto's smart casts are checked at compile time.

Type-Safe Dispatch ​

Smart casts and union types handle the "one of several types" case. But what about polymorphism -- when you want to treat different types uniformly through a shared interface? That is where specs come in (from Chapter 13).

A spec defines a set of methods that a type must implement. When a type conforms to a spec, you can use any value of that type wherever the spec is expected. This is Auto's version of interfaces, traits, or protocols.

Type-safe dispatch through specs

This prints:

[Button: Click me]
[TextField: Type here]

Let us walk through the design:

  1. spec Renderable -- Defines a contract: any type that is Renderable must have a render method that returns a String. This is the same concept as Rust's trait, Java's interface, or Swift's protocol.

  2. ext Button as Renderable -- This declares that Button conforms to the Renderable spec. The as keyword makes the conformance explicit. The ext block provides the implementation. This is Auto's equivalent of impl Renderable for Button in Rust.

  3. fn render(&self) String -- The method takes &self (a reference to the instance) and returns a String. Inside the method, self.label accesses the label field of the Button.

  4. fn render_all(items []Renderable) -- This function accepts a list of Renderable values. It does not care whether they are Buttons or TextFields -- it only cares that they implement render().

  5. render_all([b, t]) -- Auto automatically converts Button and TextField to Renderable when needed. Both types satisfy the spec, so the list is valid.

The key difference between this approach and smart casts:

  • Smart casts -- You check the type at runtime and branch. The compiler narrows the type in each branch. Best when you need different behavior for each type.

  • Specs (dispatch) -- You define a shared interface and call methods through it. The runtime dispatches to the correct implementation. Best when different types share a common behavior.

Use smart casts when the behavior differs per type. Use specs when the behavior is the same concept but implemented differently. In practice, well- designed Auto code uses both: specs for the shared interface, and smart casts for the type-specific details when needed.

In Rust, this pattern is trait + impl Trait for Type. In TypeScript, it is interface + implements. In Python, it is ABC + inheritance. In C, it is function pointers in a vtable (which is what the transpiler generates). Auto's spec + ext ... as is the most concise way to express this.

Summary ​

This chapter introduced smart casts and flow typing in Auto. Here is what we learned:

ConceptSyntaxPurpose
Union typeT | UA value that is one of several types
Type checkif x is TCheck and narrow a value's type
Exhaustive matchingis x { A => ..., B => ... }Handle every variant of an enum
Specspec Name { ... }Define a shared interface
Spec conformanceext Type as Spec { ... }Implement a spec for a type
Spec dispatchfn foo(x Spec)Call methods through a shared interface

Key takeaways:

  1. Smart casts are automatic -- After if value is T, the compiler narrows value to T within that branch. No explicit cast needed.

  2. Union types express alternatives -- int | String means "either an int or a String." The compiler tracks which types are possible at each point in the code.

  3. Exhaustiveness is enforced -- When pattern matching on enums, the compiler ensures every variant is handled. Adding a new variant is safe because the compiler flags incomplete matches.

  4. Smart casts enable type-specific methods -- After narrowing, you can call methods that only exist on the narrowed type.

  5. Specs provide polymorphism -- When different types share a behavior, use specs to define the interface and ext ... as to implement it. This is cleaner than branching on type.

Together, smart casts and specs give Auto a type system that is both safe and expressive. Smart casts handle the "I know what type this is" case. Specs handle the "I do not care what type this is, as long as it supports this behavior" case. Every well-designed program needs both.

In the next chapter, we will explore how Auto handles object-oriented programming with is, has, and spec.

Released under the MIT License.