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:
Safety -- You never accidentally use a value as the wrong type. The compiler enforces that each branch only sees the type it checked for.
Convenience -- You do not need to write explicit casts,
asoperators, orunwrap()calls. The compiler does the work for you.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.
This prints:
Integer: 42
String: helloLet us break down what is happening:
fn describe(value int | String)-- The parametervaluehas typeint | String, which is a union type. It means "value is either anintor aString." We will explore union types in the next section.if value is int-- This checks whethervalueis anint. If it is, the compiler narrowsvalue's type tointwithin theifbranch.print("Integer: " + value)-- Inside theifbranch,valueis known to be anint. The+operator works becauseintsupports concatenation withString.else-- In theelsebranch, the compiler narrowsvalue's type toString(the only remaining type in the union).print("String: " + value)-- Inside theelsebranch,valueis known to be aString. 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.
This prints:
42
3.14
helloSeveral things to notice:
int | float | String-- A three-way union type. The|operator combines types. You can have as many alternatives as you need.print(value)-- Theprintfunction accepts any value that implements display formatting. Sinceint,float, andStringall support printing, this works without narrowing. The compiler resolves the call through the shared interface.process(42)-- Auto infers that42is anint, which satisfies theint | float | Stringunion. No annotation needed.process(3.14)-- Similarly,3.14is inferred asfloat, which is part of the union.process("hello")-- A string literal satisfies theStringpart 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.
This prints:
78.53975Key points:
enum Shape-- Defines three variants, each carrying different data.Circlehas one float (radius).Recthas two floats (width and height).Trianglehas three floats (side lengths).is shape { Circle(r) => ..., Rect(w, h) => ..., Triangle(a, b, c) => ... }-- Theisexpression performs exhaustive pattern matching. Each variant is destructured, and the inner values are bound to local variables.Circle(r) => 3.14159 * r * r-- Inside this branch,ris afloat(narrowed from theCircle(float)variant). No cast needed.Rect(w, h) => w * h-- Similarly,wandhare narrowed tofloatwithin this branch.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))wheres = (a + b + c) / 2.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.
This prints:
HELLO
Not a stringThe key observation here:
v.to_upper()-- This method call only makes sense forStringvalues. Butvis declared asint | String | float. How can we call.to_upper()on it?The smart cast -- After
if v is String, the compiler narrowsvtoStringwithin theifbranch. Now.to_upper()is a valid call. The compiler knowsvis aString, so it allowsStringmethods.else-- In theelsebranch,vis narrowed toint | float(everything that is notString). Neitherintnorfloathas 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.
This prints:
[Button: Click me]
[TextField: Type here]Let us walk through the design:
spec Renderable-- Defines a contract: any type that isRenderablemust have arendermethod that returns aString. This is the same concept as Rust'strait, Java'sinterface, or Swift'sprotocol.ext Button as Renderable-- This declares thatButtonconforms to theRenderablespec. Theaskeyword makes the conformance explicit. Theextblock provides the implementation. This is Auto's equivalent ofimpl Renderable for Buttonin Rust.fn render(&self) String-- The method takes&self(a reference to the instance) and returns aString. Inside the method,self.labelaccesses thelabelfield of theButton.fn render_all(items []Renderable)-- This function accepts a list ofRenderablevalues. It does not care whether they areButtons orTextFields -- it only cares that they implementrender().render_all([b, t])-- Auto automatically convertsButtonandTextFieldtoRenderablewhen 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:
| Concept | Syntax | Purpose |
|---|---|---|
| Union type | T | U | A value that is one of several types |
| Type check | if x is T | Check and narrow a value's type |
| Exhaustive matching | is x { A => ..., B => ... } | Handle every variant of an enum |
| Spec | spec Name { ... } | Define a shared interface |
| Spec conformance | ext Type as Spec { ... } | Implement a spec for a type |
| Spec dispatch | fn foo(x Spec) | Call methods through a shared interface |
Key takeaways:
Smart casts are automatic -- After
if value is T, the compiler narrowsvaluetoTwithin that branch. No explicit cast needed.Union types express alternatives --
int | Stringmeans "either an int or a String." The compiler tracks which types are possible at each point in the code.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.
Smart casts enable type-specific methods -- After narrowing, you can call methods that only exist on the narrowed type.
Specs provide polymorphism -- When different types share a behavior, use specs to define the interface and
ext ... asto 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.