Skip to main content

Typo

The Scala + PostgreSQL toolkit that
brings your database into your type system.

Typo revolutionizes database development with unprecedented type safety, a fantastic testing story, and a DSL so intuitive it feels like cheating. Your database schema becomes your type system. Your domain model stays in sync automatically.

Fantastic testing support - Both in-memory stubs and database test helpers
Composite keys done right - First-class support with type-safe helpers
"It just works" - Complex queries work correctly the first time

Why Developers Love Typo

SQL First

Write SQL in .sql files with full IDE support. No ORMs or query builders required - just pure SQL with type-safe parameters.
-- users.sql
SELECT * FROM users
WHERE email = :email!

Type-Safe Everything

Complete type safety from database to application. Foreign keys become specific ID types, nullable columns become Option[T].
case class UserId(value: Long)
case class User(
id: UserId,
email: String,
name: Option[String]
)

Zero Boilerplate

Generates repositories with CRUD operations, streaming queries, and batch inserts. Works with Anorm, Doobie, and ZIO-JDBC.
UserRepo.insert(user)
UserRepo.selectById(userId)
UserRepo.updateEmail(userId, email)
UserRepo.selectAll.stream

Functional Relational Mapping

Not an ORM - it's FRM. Maps your database schema to immutable case classes without runtime overhead or magic. Fast compilation, zero reflection.
// FRM: Pure functions over data
// vs ORM: Complex object hierarchies
// vs hand-written SQL: Verbose boilerplate
// vs jOOQ: Better testing story

Stream Like a Pro

Built-in streaming support for large datasets. Process millions of rows without breaking a sweat using your favorite streaming library.
// Stream millions of rows efficiently
UserRepo.selectAll.stream
.filter(_.active)
.mapAsync(enrichUser)
.runWith(Sink.foreach(process))

Powerful Query DSL

Optional type-safe DSL for complex queries. Build dynamic queries with compile-time guarantees and autocomplete support.
select
.from(users)
.join(posts).on(_.id, _.userId)
.where(_.email.like("%@typo%"))
.orderBy(_.createdAt.desc)
.limit(10)

Every Feature You Need, Nothing You Don't

Typo delivers a comprehensive PostgreSQL development experience with unprecedented type safety, testing capabilities, and developer productivity features.

All The Boilerplate, None Of The Work

From Database Schema to Complete Scala Code

Point Typo at your PostgreSQL database and watch it generate everything: case classes, repositories, type-safe IDs, JSON codecs, and test helpers. No manual mapping code ever again.

-- Your PostgreSQL schema
CREATE TABLE user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
department_id UUID REFERENCES departments(id)
);
// Generated automatically:
case class UserId(value: TypoUUID)
case class UserRow(
id: UserId,
email: String,
name: String,
createdAt: Option[TypoLocalDateTime],
departmentId: Option[DepartmentsId]
)

trait UserRepo {
def selectAll(implicit c: Connection): List[UserRow]
def selectById(id: UserId)(implicit c: Connection): Option[UserRow]
def insert(unsaved: UserRowUnsaved)(implicit c: Connection): UserRow
def update(row: UserRow)(implicit c: Connection): Boolean
def deleteById(id: UserId)(implicit c: Connection): Boolean
// + 20 more methods
}

Complete CRUD + Advanced Operations

Get full repositories with not just basic CRUD, but batch operations, upserts, streaming inserts, and optional tracking methods. All generated, all type-safe.

// All generated automatically from your schema:

// Basic operations
userRepo.selectById(UserId(uuid))
userRepo.insert(unsavedUser)
userRepo.update(user.copy(name = "New Name"))
userRepo.deleteById(userId)

// Batch operations
userRepo.upsertBatch(users) // Returns the upserted rows

// Advanced operations
userRepo.selectByIds(userIds)
userRepo.selectByIdsTracked(userIds) // tracks found/missing
userRepo.insertStreaming(userStream) // PostgreSQL COPY API

Relationships Become Navigation

Foreign Keys Drive Everything

Every foreign key in your database automatically generates navigation methods, type-safe joins, and reverse lookups. Your schema relationships become first-class code citizens.

-- Database relationships
CREATE TABLE order (
id UUID PRIMARY KEY,
user_id UUID REFERENCES user(id),
product_id UUID REFERENCES product(id)
);
// Generated from foreign keys:
case class OrderRow(
id: OrderId,
userId: Option[UserId], // Type flows through relationships
productId: Option[ProductId]
)

// Type-safe DSL with automatic foreign key joins:
orderRepo.select
.joinFk(_.fkUser)(userRepo.select) // Auto-joins via foreign key
.where { case (_, user) => user.email === Email("admin@company.com") }

// joinFk knows the relationship from your schema!
// Your IDE will autocomplete available foreign keys

Type-Safe Foreign Key Navigation

Typo's DSL provides joinFk for easy type-safe navigation through foreign key relationships. Your IDE knows exactly what's available at each level.

-- Database with foreign key relationships
CREATE TABLE product (
id UUID PRIMARY KEY,
model_id UUID REFERENCES product_model(id),
subcategory_id UUID REFERENCES product_subcategory(id)
);
CREATE TABLE product_subcategory (
id UUID PRIMARY KEY,
category_id UUID REFERENCES product_category(id)
);
// Navigate through multiple foreign keys with perfect type safety:
val query = productRepo.select
.joinFk(_.fkProductModel)(productModelRepo.select)
.joinFk { case (p, _) => p.fkProductSubcategory }(productSubcategoryRepo.select)
.joinFk { case ((_, _), ps) => ps.fkProductCategory }(productCategoryRepo.select)
.where { case (((product, model), subcategory), category) =>
product.inStock === true &&
category.name === "Electronics"
}

// Each joinFk automatically uses the foreign key constraint
// No manual ON clauses needed - Typo knows the relationships!

Type Safety Revolution

Strongly-Typed Primary Keys

Every table gets its own ID type that flows through foreign key relationships. No more mixing up User IDs and Product IDs.

case class UserId(value: TypoUUID)
case class ProductId(value: TypoUUID)

// Compile error if you mix them up!
def getUserOrders(userId: UserId): List[OrderRow] = {
orderRepo.select
.where(_.userId === userId.?)
.toList
// orderRepo.select.where(_.userId === productId.?) // ❌ Won't compile
}

Type Flow Through Relationships

Foreign key relationships automatically propagate specific types throughout your domain model.

-- Database schema creates type flow
CREATE TABLE user (
id UUID PRIMARY KEY,
name TEXT
);

CREATE TABLE order (
id UUID PRIMARY KEY,
user_id UUID REFERENCES user(id)
);
// Generated code maintains relationships
case class UserRow(id: UserId, name: String)
case class OrderRow(id: OrderId, userId: Option[UserId]) // ✅ Specific type, not just UUID

PostgreSQL Domain Types

Full support for PostgreSQL domains with constraint documentation in your generated code.

-- Database domain
CREATE DOMAIN email AS TEXT CHECK (VALUE ~ '^[^@]+@[^@]+\.[^@]+$');
// Generated Scala code with constraint docs
/** Domain: frontpage.email
* Constraint: CHECK ((VALUE ~ '^[^@]+@[^@]+\.[^@]+$'::text))
*/
case class Email(value: String)

// Usage in generated types:
case class UserRow(id: UserId, email: String) // Type preserved

Composite Primary Keys

First-class support for composite primary keys with generated helper types and methods.

-- Composite key table
CREATE TABLE user_permission (
user_id UUID REFERENCES user(id),
permission_id UUID REFERENCES permission(id),
granted_at TIMESTAMP,
PRIMARY KEY (user_id, permission_id)
);
// Generated composite key row:
case class UserPermissionRow(
userId: UserId,
permissionId: PermissionId,
grantedAt: Option[TypoLocalDateTime]
)

// Repository uses composite key directly:
userPermissionRepo.insert(UserPermissionRowUnsaved(
userId = userId,
permissionId = permissionId
))

The Perfect DSL For Real-World Data Access

Incredibly Easy To Work With

A pragmatic DSL that makes everyday data operations a breeze. Perfect IDE support with autocomplete, inline documentation, and compile-time validation. Focused on what you do most: fetching, updating, and deleting data with complex joins and filters.

// Fetch exactly the data you need with type-safe joins
val activeOrdersWithDetails = orderRepo.select
.join(customerRepo.select)
.on((o, c) => o.userId === c.userId)
.join(productRepo.select)
.on { case ((o, _), p) => o.productId === p.id.? }
.where { case ((order, _), _) => order.status === "active".? }
.where { case ((_, customer), _) => customer.verified === true.? }
.where { case (_, product) => product.inStock === true.? }
.orderBy { case ((order, _), _) => order.createdAt.desc }
.limit(100)
.toList // Execute and get results

// Update with complex conditions
productRepo.update
.set(_.inStock, Some(false))
.set(_.lastModified, Some(TypoLocalDateTime.now))
.where(_.quantity === 0.?)
.where(_.lastRestocked < thirtyDaysAgo.?)
.execute

// Delete with conditions
orderItemRepo.delete
.where(_.orderId.in(cancelledOrderIds))
.where(_.shippedAt.isNull)
.execute

Pure SQL Files as First-Class Citizens

Write Real SQL For Complex Queries

When you need aggregations, window functions, or complex analytics, write real SQL in dedicated .sql files. Typo analyzes your queries and generates perfectly typed Scala methods - the best of both worlds.

-- sql/user-analytics.sql
SELECT
u.name,
u.email,
COUNT(o.id) as order_count,
SUM(o.total) as lifetime_value,
MAX(o.created_at) as last_order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= :start_date:LocalDate!
AND u.status = :status:UserStatus?
AND (:min_orders? IS NULL OR COUNT(o.id) >= :min_orders)
GROUP BY u.id, u.name, u.email
HAVING SUM(o.total) > :min_value:BigDecimal!
ORDER BY lifetime_value DESC
LIMIT :limit:Int!
// Generated automatically:
trait UserAnalyticsSqlRepo {
def apply(
startDate: LocalDate,
status: Option[String] = None,
minValue: BigDecimal,
limit: Int
)(implicit c: Connection): List[UserAnalyticsSqlRow]
}

Smart Parameter Inference

Typo analyzes your SQL parameters against the database schema to infer exact types. Override nullability and types as needed with simple annotations.

-- Advanced parameter syntax
SELECT p.*, a.city, e.salary
FROM persons p
JOIN addresses a ON p.address_id = a.id
LEFT JOIN employees e ON p.id = e.person_id
WHERE p.id = :person_id! -- Required parameter
AND p.created_at >= :since! -- Required parameter
AND a.country = :country:String? -- Optional string parameter
AND (:max_salary? IS NULL OR e.salary <= :max_salary)

-- Dynamic filtering patterns work perfectly
-- Type inference follows foreign keys
-- Custom domain types are preserved

Updates with RETURNING Support

Write UPDATE, INSERT, and DELETE operations in SQL files. Full support for RETURNING clauses with type-safe result parsing.

-- sql/update-user-status.sql
UPDATE user
SET
status = :new_status:frontpage.user_status!,
created_at = NOW()
WHERE id = :user_id!
AND status != :new_status
RETURNING
id,
name,
status,
created_at as "modified_at:java.time.LocalDateTime!"
// Generated method returns updated rows:
trait UpdateUserStatusSqlRepo {
def apply(
newStatus: String,
userId: TypoUUID
)(implicit c: Connection): List[UpdateUserStatusSqlRow]
}

// Perfect for audit trails and optimistic locking

Testing Excellence

TestInsert: Build Valid Data Graphs

Generate complete object graphs with valid foreign key relationships. All fields are random by default, but you override exactly what your test cares about. Eliminates the 'lingering test state' problem forever.

val testInsert = new TestInsert(new Random(42))

// Build a complete, valid data graph
val company = testInsert.frontpageCompanies(name = "Acme Corp")
val department = testInsert.frontpageDepartments(companyId = Some(company.id))
val manager = testInsert.frontpageUsers(
departmentId = Some(department.id),
role = Defaulted.Provided(Some(UserRole.manager))
)
val employees = List.fill(5)(
testInsert.frontpageUsers(
departmentId = Some(department.id),
managerId = Some(manager.id),
role = Defaulted.Provided(Some(UserRole.employee))
)
)

// Every foreign key is valid!
// All other fields are realistic random data!
// Zero lingering state between tests!

In-Memory Repository Stubs

Drop-in repository replacements that work entirely in memory. Run huge parts of your application without a database - perfect for unit tests and development.

// Replace real repos with in-memory stubs
val userRepo = UserRepoMock.empty
val orderRepo = OrdersRepoMock.empty
val productRepo = ProductsRepoMock.empty

// Seed with test data
userRepo.insertUnsaved(testUsers: _*)
orderRepo.insertUnsaved(testOrders: _*)
productRepo.insertUnsaved(testProducts: _*)

// Your entire business logic works!
val orderService = new OrderService(userRepo, orderRepo, productRepo)
val result = orderService.calculateMonthlyReport(userId)

// Runs instantly, no database needed
// Full DSL support including complex joins

Full DSL Support in Stubs

Unlike other testing libraries, Typo's mocks support the complete DSL including complex joins and filtering. Your business logic runs unchanged.

// Complex queries work in memory!
val topCustomers = userRepo.select
.join(orderRepo.select)
.on((u, o) => u.id === o.userId.?)
.join(productRepo.select)
.on { case ((_, o), p) => o.productId === p.id.? }
.where { case ((user, _), _) => user.status === "active".? }
.where { case (_, product) => product.price > BigDecimal("100") }
.limit(50)
.toList

// This runs instantly in memory!
// Same code as production database queries!

Advanced PostgreSQL Integration

Unprecedented PostgreSQL Array Support

First-class support for PostgreSQL arrays with type-safe operations. Use arrays naturally in queries with .in(), arrayOverlaps, arrayConcat, and array indexing.

// Full array support for all PostgreSQL types
case class ProductRow(
id: ProductsId,
name: String,
tags: Option[Array[String]], // TEXT[]
categories: Option[Array[Int]], // INTEGER[]
prices: Option[Array[BigDecimal]], // NUMERIC[]
attributes: Option[Array[TypoJsonb]] // JSONB[]
)

// Array operations in queries
productRepo.select
.where(_.id.in(Array(id1, id2, id3)))
.where(_.tags.getOrElse(Array.empty).contains("sale"))
.toList

Other PostgreSQL Types & Features

Support for geometric types, network types, JSON/JSONB, XML, and more. If PostgreSQL has it, Typo supports it.

// Geometric and network types
case class LocationRow(
id: LocationsId,
position: Option[TypoPoint], // POINT
area: Option[TypoPolygon], // POLYGON
ipRange: Option[TypoInet], // INET
metadata: Option[TypoJsonb] // JSONB
)

// Types are preserved and can be used in queries
locationRepo.select
.where(_.name === "Main Office")
.toList

Performance & Scalability

Streaming Bulk Operations

PostgreSQL COPY API integration for high-performance bulk inserts and updates.

// Streaming insert using PostgreSQL COPY
val users = Iterator.range(1, 1000000).map(i =>
UserRowUnsaved(
name = s"User $i",
email = s"user$i@example.com"
)
)

// Streams directly to PostgreSQL COPY API
val inserted = userRepo.insertUnsavedStreaming(users)
println(s"Inserted $inserted records in seconds")

// Batch operations - returns the upserted rows
val upsertedRows = userRepo.upsertBatch(usersList)
println(s"Upserted ${upsertedRows.length} rows")

Efficient Batch Operations

Optimized batch insert, update, and delete operations with detailed result tracking.

// True batch operations - single database roundtrip!
val newUsers = List(
UserRowUnsaved(email = Email("user1@example.com"), name = "User 1"),
UserRowUnsaved(email = Email("user2@example.com"), name = "User 2"),
UserRowUnsaved(email = Email("user3@example.com"), name = "User 3")
)

// Batch upsert - returns all upserted rows
val upsertedUsers = userRepo.upsertBatch(newUsers)
println(s"Upserted ${upsertedUsers.length} users")

// Batch delete by IDs
val deleted = userRepo.deleteByIds(Array(userId1, userId2, userId3))
println(s"Deleted $deleted rows")

// Streaming batch operations for huge datasets
val millionUsers = Iterator.range(1, 1000000).map(i =>
UserRowUnsaved(email = Email(s"user$i@example.com"), name = s"User $i")
)
userRepo.insertUnsavedStreaming(millionUsers) // Uses PostgreSQL COPY

Multi-Library Support

Choose Your Database Library

Full support for Anorm, Doobie, and ZIO-JDBC with library-specific optimizations.

// Anorm (Play Framework)
class UserController @Inject()(userRepo: UserRepo, db: Database) {
def getUser(id: UserId) = Action {
db.withConnection { implicit c =>
userRepo.selectById(id) match {
case Some(user) => Ok(Json.toJson(user))
case None => NotFound
}
}
}
}

// Doobie (Cats Effect)
def getActiveUsers: ConnectionIO[List[UserRow]] =
userRepo.select
.where(user => user.status === "active".?)
.toList

// ZIO-JDBC
def getUsersZIO: ZIO[Connection, Throwable, List[UserRow]] =
ZIO.serviceWithZIO[Connection](userRepo.selectAll(_))

JSON Library Integration

Typo generates JSON codecs for Play JSON, Circe, and ZIO JSON - no manual derivation needed.

// Typo generates all JSON codecs for you!

// Play JSON - generated in UserRow companion
implicit val usersReads: Reads[UserRow] = UserRow.reads
implicit val usersWrites: Writes[UserRow] = UserRow.writes

// Circe - generated in UserRow companion
implicit val usersDecoder: Decoder[UserRow] = UserRow.decoder
implicit val usersEncoder: Encoder[UserRow] = UserRow.encoder

// ZIO JSON - generated in UserRow companion
implicit val usersCodec: JsonCodec[UserRow] = UserRow.codec

// Just use them - handles all complex types, arrays, nested objects
val json = Json.toJson(user)
val decoded = json.as[UserRow]

And Much More...

Advanced Customization: Type overrides, nullability control, custom naming conventions
Enterprise Ready: Transaction support, CI/CD integration, version control friendly
Developer Experience: Real-time code generation, IDE integration, comprehensive logging
PostgreSQL Deep Integration: Comprehensive array support with operations, enums, domains, geometric types, network types

Why Teams Choose Typo Over Everything Else

vs. Traditional ORMs

  • Zero complexity debt - No entity managers, session state, or lazy loading issues
  • Predictable performance - No N+1 queries or hidden roundtrips
  • SQL-first - Use the full power of PostgreSQL, not a subset
  • Debuggable - See exactly what SQL runs, no abstraction layers

vs. Writing SQL by Hand

  • Zero boilerplate - No manual mapping code ever
  • Type safety - Catch errors at compile time, not runtime
  • Faster compilation - No runtime reflection or macro magic
  • Automatic updates - Schema changes = instant code updates

vs. JOOQ

  • Stronger type safety - Specific ID types and proper nullability with Option[T]
  • Open source - No commercial licensing headaches
  • Scala-native - Idiomatic code, not Java translations
  • PostgreSQL-focused - Deep integration, not generic

Ready to Ship Faster with Fewer Bugs?

Join developers who've discovered the joy of type-safe database development.