Precise Types
Precise Types bring your database's size and precision constraints directly into your type system. Instead of String for a VARCHAR(50), you get String50 - a type that the compiler guarantees fits within 50 characters.
This is type safety taken to the next level.
The Problemβ
Consider this common scenario:
// Database: VARCHAR(50)
record Customer(String firstName, String lastName) {}
// Compiles fine, but will fail at runtime!
Customer customer = new Customer(
"A".repeat(100), // 100 chars into VARCHAR(50) - boom!
"Smith"
);
Runtime failures are expensive. They happen in production, require debugging, and erode trust in your code.
The Solutionβ
With Precise Types enabled:
// Database: VARCHAR(50)
record Customer(String50 firstName, String50 lastName) {}
// This won't even compile - the type system protects you
Customer customer = new Customer(
String50.unsafeForce("A".repeat(100)), // Throws IllegalArgumentException
String50.of("Smith").orElseThrow() // Safe: returns Optional<String50>
);
Supported Precise Typesβ
| Type | Database Source | Constraints |
|---|---|---|
StringN | VARCHAR(n), NVARCHAR(n) | Maximum length |
NonEmptyStringN | VARCHAR(n) NOT NULL with CHECK | Non-empty, max length |
PaddedStringN | CHAR(n) | Exact length, space-padded |
NonEmptyPaddedStringN | CHAR(n) with CHECK | Exact length, non-empty |
BinaryN | BINARY(n), VARBINARY(n) | Maximum byte length |
DecimalN | DECIMAL(p,s), NUMERIC(p,s) | Precision and scale |
LocalDateTimeN | DATETIME(n) | Fractional seconds precision |
LocalTimeN | TIME(n) | Fractional seconds precision |
InstantN | TIMESTAMP(n) | Fractional seconds precision |
OffsetDateTimeN | TIMESTAMP(n) WITH TIME ZONE | Fractional seconds precision |
Generated Type APIβ
Each precise type provides a rich API for safe value construction:
Safe Constructionβ
// Returns Optional.empty() if validation fails
Optional<String50> maybeValid = String50.of("Hello World");
// Use in functional chains
String50 name = String50.of(userInput)
.orElseThrow(() -> new ValidationException("Name too long"));
Truncationβ
// Automatically truncates to fit - great for user input
String50 truncated = String50.truncate("This is a very long string...");
Forced (Fail-Fast)β
// Throws IllegalArgumentException if validation fails
// Use when you know the value is valid (e.g., from database)
String50 forced = String50.unsafeForce("Known valid string");
Common Interfaceβ
All string types implement StringN, enabling generic code:
public <T extends StringN> void logLength(T value) {
System.out.println("Value: " + value.rawValue() +
" (max: " + value.maxLength() + ")");
}
Decimal Precisionβ
Decimal types enforce both precision (total digits) and scale (decimal places):
// DECIMAL(10,2) - perfect for currency
Optional<Decimal10_2> price = Decimal10_2.of(new BigDecimal("99.99"));
Decimal10_2 zero = Decimal10_2.Zero; // Convenient constant
// Integer convenience methods
Decimal10_2 fromInt = Decimal10_2.of(100); // Safe for small values
// Automatic scaling
Decimal10_2.of(new BigDecimal("99.999")); // Rounds to 99.99
Semantic Equalityβ
Precise types support semantic equality - two values are equal if their content matches, regardless of declared constraints:
String10 short_ = String10.unsafeForce("hello");
String50 long_ = String50.unsafeForce("hello");
// Different types, but semantically equal
short_.semanticEquals(long_); // true
// Works in collections too
Set<StringN> names = new HashSet<>();
names.add(short_);
names.contains(long_); // true (using semanticHashCode)
Enabling Precise Typesβ
import typr.*
val options = Options(
pkg = "myapp",
lang = Lang.Java,
dbLib = Some(DbLibName.Typo),
enablePreciseTypes = Selector.All // Enable for all tables
)
Selective Enablementβ
// Only for specific schemas
enablePreciseTypes = Selector.schemas("production", "sales")
// Only for specific tables
enablePreciseTypes = Selector.relationNames("customers", "products")
// Custom predicate
enablePreciseTypes = Selector.predicate { relation =>
relation.name.value.endsWith("_strict")
}
Database Supportβ
Precise Types work across all supported databases:
| Database | VARCHAR | CHAR | DECIMAL | TIME | TIMESTAMP |
|---|---|---|---|---|---|
| PostgreSQL | StringN | PaddedStringN | DecimalN | LocalTimeN | InstantN / OffsetDateTimeN |
| MariaDB | StringN | PaddedStringN | DecimalN | LocalTimeN | LocalDateTimeN |
| SQL Server | StringN | PaddedStringN | DecimalN | LocalTimeN | LocalDateTimeN / OffsetDateTimeN |
| Oracle | StringN | PaddedStringN | DecimalN | - | InstantN |
| DuckDB | StringN | - | DecimalN | LocalTimeN | InstantN |
Language Supportβ
Precise Types generate idiomatically for each language:
Javaβ
public record String50(String value) implements StringN {
public static Optional<String50> of(String value) { ... }
public static String50 truncate(String value) { ... }
public static String50 unsafeForce(String value) { ... }
}
Kotlinβ
@JvmInline
value class String50(val value: String) : StringN {
companion object {
fun of(value: String): String50? = ...
fun truncate(value: String): String50 = ...
fun unsafeForce(value: String): String50 = ...
}
}
Scalaβ
opaque type String50 = String
object String50 extends StringN[String50]:
def of(value: String): Option[String50] = ...
def truncate(value: String): String50 = ...
def unsafeForce(value: String): String50 = ...
Why This Mattersβ
Precise Types transform database constraints from runtime concerns to compile-time guarantees:
- Catch bugs earlier: Size violations are caught during development, not in production
- Self-documenting:
String50tells you more thanString - IDE support: Autocomplete shows exact constraints
- Refactoring safety: Change a column's size and the compiler shows all affected code
- API contracts: Public APIs communicate constraints through types
This is the difference between hoping your data is valid and knowing it is.