Taxi 1.70 - More type checker strictness and callable operations

Date

Taxi 1.70 will be out shortly, and brings with it a couple of important changes. This will be available in Orbital 0.37.x, and are available on next builds now.

🚀 New feature: Calling operations directly

As part of making Taxi more approachable, and reducing the learning curve for people who are new to semantic layers, we’ve added the ability for operations to be called directly, similar to a function.

So, if you have the following schema:

model Person {
   name : PersonName inherits String
}
service PersonService {
   operation getPerson(emailAddress: String):Person
}

You can do the following:

Calling an API operation as a function

Schema (just the highlights)
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

This doesn’t just work with Taxi schemas - any supported API spec that exposes operations - such as SOAP, OpenAPI or Protobuf will work.

You can use these expressions anywhere in a query too.

For example:

Calling an API operation in a query

Schema (just the highlights)
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

Operation calls are just expressions, so can be chained with other types of expressions too:

Schema (just the highlights)
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

Sensible caching

When Orbital executes these statements, sensible caching applies - so even if you make multiple references to the same operation call with the same parameters (eg: getPerson("jimmy")) - the operation itself is only invoked once in the query.

🚨 Breaking Change - Stricter type checking

A couple of bugs have been fixed in the type checker. These are all cases that should’ve been raised as compiler errors previously, but weren’t.

Mismatched scalar and object assignments

Previously, in certain scenarios, the typechecker wouldn’t correctly detect if you tried to assign a non-scalar value (like an object with properties) to a scalar field.

e.g., The following is invalid:

model Person {
   name : FirstName inherits String
}

given { p:Person = { name: "Jimmy"} }
find { Person } as {
    name : FirstName = p // <-- this shouldn't be allowed 
}

This has been fixed, and will now raise a compiler error.

Mismatched return types from expressions

This statement previously would be allowed to compile:

model Person {
   age : Age inherits Int
}
// This should not compile - Age is not assignable to Person
type Adults inherits Person = (Person[](Age > 18)) -> Person[].first()::Age

This is incorrect, as Age is of type Int, and Person is a person. And we - as people - are not defined by our age. The compiler now picks this up, and correctly reports the error

🚨 Breaking Change - Type annotations required for ambiguous expressions

When declaring a field with an expression, you must now explicitly specify the field’s type if the compiler cannot infer it.

Problem: The compiler cannot distinguish between a type reference and a function call in certain cases.

Example:

enum Tier {
   Gold, Silver, Bronze
}
model Customer {
   // ❌ Doesn't compile - ambiguous syntax
   tier: Tier.enumForName('Gold')

   // ✅ Add explicit type annotation
   tier: Tier = Tier.enumForName('Gold')
}

Migration: Review field declarations that use expressions without an assignment operator (=) and add explicit type annotations where needed.

Note: We’re working to improve type inference so these annotations won’t be necessary in future releases.

🚨 Breaking Change - Excluding data from Policies

The compiler now enforces that policy return types must satisfy their declared contracts.

Previously, the except operator would remove fields from the return type, breaking the contract even though the code would compile.

What changed:

When you use ... except { fieldName }, it creates a new type without that field. For example, if a policy is declared against Film but returns a type missing a field (eg: yearReleased), it no longer satisfies the Film contract.

Orbital has always handled this at runtime by setting excluded fields to null to honor the contract. The compiler now reflects this existing runtime behavior, requiring you to explicitly declare excluded fields as null rather than removing them entirely.

Migration:

Old approach - no longer compiles:

policy FilmsPolicy against Film {
   // Returns a type missing yearReleased, violating the Film contract
   read { Film as { ... except { yearReleased } } }
}

New approach - explicitly set excluded fields to null:

policy FilmsPolicy against Film {
   read { Film as {
      yearReleased: YearReleased = null
      ...
   }
 }
}

This syntax accurately reflects runtime behavior and maintains type safety by ensuring the policy returns a valid Film type.

Enhanced type checking rules - Structural compatability

If you’re interested in type systems and compiler internals - this one’s for you. If not, you’re unlikely to encounter these rules today, so you can skip this bit.

Keen readers may have noticed something else in the above example - which is that the result of the policy looks like a Film, but is a new type - that isn’t a film at all.

For this to be acceptable by the compiler, we’ve introduced Structural Compatability rules - the equivalence of Duck Typing - taking inspriation from Typescript.

These rules determine if model A can be assigned to model B. E.g., in the policy example, the rules determine if the result of the policy (which is an anonymous type) can be assigned to the contract’s declared return type - which is Film.

Until now, we’ve used nominal assignability checks - which follow rules like you would see in a typical statically typed language - like Kotlin, Java, C#, etc.

Structural typing rules now apply only on non-scalar structures.

For example, these two types are considered to be structurally equivalent:

model Film {
   title : Title inherits String
   yearReleased: YearReleased inherits Int
}

model Movie {
   title : Title
   yearReleased : YearReleased
}

Specifically, A is structurally assignable to B when:

  • A and B are both non-scalar types
  • A contains all the fields that B requires
  • All the fields are semantically assignable (using the standard rules)

Here’s some more examples:

Structurally compatible:

// These ARE structurally compatible:
// ie., EnrichedPerson can be assigned to Person (but not vice-versa)
model Person { name: PersonName, age: Age }
model EnrichedPerson { name: PersonName, age: Age, email: Email } 

Not structurally compatible:

// These are not:
type PersonName inherits String
type DogName inherits String

model Person { name: PersonName }
model Dog { name: DogName }

So, Dog and Person are not structurally compatible, because even though they share the same fields and field names - and even though all those fields are String based — the semantic types of DogName and PersonName are not interoperable.

Today, these rules primarily apply internally, for allowing things like the policies above to compile.

In the future, this gives a foundation for better language features that are both pragmatic, and type-safe.