Junior developers
Productive on day one.
Don't need the whole‑system map in their head. The types tell them what's possible — wrong ID type? wrong response shape? Won't compile.
Type‑safe code, generated for every boundary in your stack — databases, REST APIs, Avro topics, gRPC services. One domain type definition. The compiler enforces every contract.
Database to API. API to event bus. Event bus to RPC service. Each crossing is a translation, and every translation is an opportunity to drop, mistype, or quietly mangle a field.
Most teams paper over this with hand‑written DTOs, mapper classes, and a slow‑burning fear of refactoring. Typr takes the opposite approach: read the schema, generate the types, let the compiler enforce the boundary.
Customer, four boundaries, four alignment policies. The anchor (postgres) defines the shape; the others align — exact, superset, or subset — and Typr generates the mappers between them.Same feature. Same domain. Different consequences.
Every ID is a Long. Every name is a String. Mix them up and the compiler waves you through.
// API types (you write these)
record CreateOrderRequest(Long userId, BigDecimal amount) {}
record OrderResponse(Long id, Long userId, String status) {}
// DB types (you write these too)
record OrderEntity(Long id, Long userId, BigDecimal amount, String status) {}
// The mapper (you write this)
class OrderMapper {
OrderEntity toEntity(CreateOrderRequest req) {
return new OrderEntity(null, req.userId(), req.amount(), "pending");
}
OrderResponse toResponse(OrderEntity e) {
return new OrderResponse(e.id(), e.userId(), e.status());
}
}
class OrderService {
// BUG: userId and orderId are both Long — this compiles fine.
User getUser(Long orderId) {
return userRepository.findById(orderId);
}
}
// API types (you write these)
data class CreateOrderRequest(val userId: Long, val amount: BigDecimal)
data class OrderResponse(val id: Long, val userId: Long, val status: String)
// DB types (you write these too)
data class OrderEntity(val id: Long?, val userId: Long, val amount: BigDecimal, val status: String)
// The mapper (you write this)
class OrderMapper {
fun toEntity(req: CreateOrderRequest) = OrderEntity(null, req.userId, req.amount, "pending")
fun toResponse(e: OrderEntity) = OrderResponse(e.id!!, e.userId, e.status)
}
class OrderService {
// BUG: userId and orderId are both Long — this compiles fine.
fun getUser(orderId: Long): User {
return userRepository.findById(orderId)
}
}
// API types (you write these)
case class CreateOrderRequest(userId: Long, amount: BigDecimal)
case class OrderResponse(id: Long, userId: Long, status: String)
// DB types (you write these too)
case class OrderEntity(id: Option[Long], userId: Long, amount: BigDecimal, status: String)
// The mapper (you write this)
class OrderMapper {
def toEntity(req: CreateOrderRequest) = OrderEntity(None, req.userId, req.amount, "pending")
def toResponse(e: OrderEntity) = OrderResponse(e.id.get, e.userId, e.status)
}
class OrderService {
// BUG: userId and orderId are both Long — this compiles fine.
def getUser(orderId: Long): User =
userRepository.findById(orderId)
}
Generated distinct types. The compiler refuses the bug before it leaves your editor.
// Generated types — you write NOTHING.
// UserId, OrderId, OrderRow, OrderRowUnsaved,
// CreateOrderRequest, OrderResponse — all generated.
class OrderService {
OrderResponse createOrder(CreateOrderRequest req) {
OrderRow saved = orderRepo.insert(
new OrderRowUnsaved(req.userId(), req.amount()), conn);
return new OrderResponse(saved.id(), saved.userId(), saved.status());
}
// Won't compile: OrderId and UserId are different types.
User getUser(OrderId orderId) {
return userRepo.selectById(orderId);
// ^^^^^^^
// error: required UserId, found OrderId
}
}
// Generated types — you write NOTHING.
// UserId, OrderId, OrderRow, OrderRowUnsaved,
// CreateOrderRequest, OrderResponse — all generated.
class OrderService {
fun createOrder(req: CreateOrderRequest): OrderResponse {
val saved = orderRepo.insert(
OrderRowUnsaved(req.userId, req.amount), conn)
return OrderResponse(saved.id, saved.userId, saved.status)
}
// Won't compile: OrderId and UserId are different types.
fun getUser(orderId: OrderId): User {
return userRepo.selectById(orderId)
// ^^^^^^^
// error: required UserId, found OrderId
}
}
// Generated types — you write NOTHING.
// UserId, OrderId, OrderRow, OrderRowUnsaved,
// CreateOrderRequest, OrderResponse — all generated.
class OrderService:
def createOrder(req: CreateOrderRequest): OrderResponse =
val saved = orderRepo.insert(
OrderRowUnsaved(req.userId, req.amount))
OrderResponse(saved.id, saved.userId, saved.status)
// Won't compile: OrderId and UserId are different types.
def getUser(orderId: OrderId): User =
userRepo.selectById(orderId)
// ^^^^^^^
// error: required UserId, found OrderId
New hires, contractors, AI agents — they don't carry your mental model. Typr gives them a working one, enforced.
Productive on day one.
Don't need the whole‑system map in their head. The types tell them what's possible — wrong ID type? wrong response shape? Won't compile.
Limited blast radius.
Implement against a typed interface. They cannot accidentally break what they cannot touch. Safe delegation by construction.
Constrained by the compiler.
Every type error is instant feedback. Fix, compile, fix, compile — tight loops save tokens and time. Guardrails outperform prompts.
Review contracts, not plumbing.
Approve schema and API changes — the implementation has to follow the types. High‑leverage decisions, low‑drag review.
Most generators flatten your contracts into the lowest common primitive. Typr preserves every distinction your schema cared enough to make.
| declared | others | typr |
|---|---|---|
CHAR(50) | String | PaddedString50 |
VARCHAR(100) | String | VarcharMax100 |
users.id (PK) | Long | UserId |
orders.user_id (FK) | Long | UserId |
DEFAULT NOW() | Timestamp | Defaulted<OffsetDateTime> |
UUID[] | — | Array<UUID> |
composite type | — | record / data class |
| declared | others | typr |
|---|---|---|
| 200 / 404 responses | Object | sealed ADT |
| userId path param | String | UserId |
nullable: true | T | Optional<T> / T? |
enum: [a, b, c] | String | MyEnum |
| Server interface | loose types | exact contract |
Add a second boundary and Typr validates that shared types stay consistent. Add a third and the validation compounds.
Six engines: PostgreSQL, MariaDB, Oracle, SQL Server, DuckDB, DB2. Row types, ID types, repositories, type‑safe DSL queries. Full DDL fidelity — composite types, arrays, enums, domains, defaults.
Sealed response types. Server stubs and client stubs that share the same interface. Same UserId in your handler as in your database row.
Avro schemas in, typed producers and consumers out. Multi‑event topics with sealed interfaces. Built‑in Kafka RPC support — Spring, Quarkus, Cats Effect.
Proto definitions map to your domain types with bidirectional, type‑safe converters. Enforced compatibility policies between proto and Java/Kotlin/Scala.
Declare a domain type, anchor it to a primary boundary, and align other sources to it. Typr validates compatibility and generates every mapper.
typr check — fails on schema drift# typr.yaml — define your domain once
domainTypes:
Customer:
primary: postgres:sales.customer # anchor boundary
fields:
id: CustomerId
firstName: FirstName
lastName: LastName
email: Email?
alignedSources:
mariadb:customers: superset # legacy DB
api:Customer: exact # OpenAPI contract
kafka:CustomerCreated: subset # event topic
# Optional: per-field type rules
fieldTypes:
CustomerId:
db: { column: [customer_id], primary_key: true }
FirstName:
db: { column: [first_name] }
Point typr at your schemas. Get back a typed surface for every boundary. Let the compiler do the work that used to live in your head.