The Typical Code Language.
Table of Contents

Decimal Types

1. Overview

Typical’s decimal types provide fixed-point decimal arithmetic with explicit control over precision and scale. Designed for serious domains like finance, scientific computing, and safety-critical systems, decimals in Typical prioritize precision and correctness, while still delivering respectable performance through SIMD optimizations and efficient backing implementations.

The decimal type family offers a spectrum of options, from ergonomic 128-bit-backed fixed decimals (decimalN) to highly customizable fixed-size variants (d32xN, d64xN) and an infinitely precise variable-width decimal type (decimal) for edge cases and maximum flexibility.

Key design goals of Typical decimals include:

Predictability: No surprises from implicit casts, silent precision loss, or runtime errors like NaN or infinity.

Performance: SIMD-friendly fixed-size decimals and Rust-backed implementations provide efficient arithmetic without sacrificing correctness.

Ergonomics: Syntax and semantics closely follow TypeScript conventions, minimizing learning friction.

LLM Friendliness: Compiler-enforced explicitness combined with simple, universal operators like the ! cast enable reliable AI code generation.

Safety: Static typing and no dynamic dispatch ensure all decimal operations are analyzable and safe at compile time.

Overall, Typical decimals are a first-class numeric family, designed to “just work” for use cases where precision and correctness are non-negotiable, without the usual ergonomic or tooling trade-offs seen in other languages.

2. Decimal Categories

2.1 Ergonomic Decimals (decimalN)

Ergonomic decimals are the flagship fixed-point decimal types in Typical, backed by a 128-bit integer representation. They are named decimal1, decimal2, ..., up to decimal128, where the number suffix indicates the number of decimal places of precision. These types use a fixed-point approach, meaning the decimal point is implicitly fixed based on the type.

Backing: All decimalN variants are backed by a 128-bit integer (scaled integer)

Precision: Fixed number of decimal places (N)

Integer Range: The integer portion automatically adjusts based on the decimal places (e.g., more decimal places reduce the integer range)

Performance: SIMD-optimized arithmetic leveraging Rust decimal libraries under the hood

Use cases: General purpose decimals where ergonomics, reasonable performance, and correctness matter most

Syntax: Simple and concise, e.g., const price: decimal4 = 19.99;

2.2 Performance Decimals (d32xN, d64xN)

Performance decimals provide finer control over the backing size and decimal precision, useful when both speed and precision are critical. They come in two variants:

Here, the developer specifies both the bit-width and the decimal precision explicitly, trading off ergonomics for maximal control and efficiency.

2.3 Dynamic Decimal (decimal)

The dynamic decimal type offers variable precision and variable integer width, suitable for use cases where extreme precision or very large numbers are required and performance is not a core concern.

3.1 Explicit and Implicit Numeric Coercion Rules

Typical enforces clear, consistent rules for numeric type coercion to preserve precision and avoid accidental lossy behavior. There are two modes of coercion: implicit (safe) and explicit (forceful via !).

Implicit Coercion (Safe)

Implicit coercion is only allowed when the following conditions are met:

  1. Same category:

    • Coercion is only allowed within the same numeric category (e.g., int → int, decimal → decimal, float → float).
    • No implicit coercion between distinct categories (e.g., intdecimal, decimalfloat, floatint, etc.).
  2. Width-safe:

    • A value of a narrower numeric type may be implicitly coerced into a wider one if the wider type can fully represent the range of the narrower type without loss.

      • Example: u8 → u16 is allowed.
      • Example: decimal2 → decimal8 is allowed.
    • Widening coercions across fixed decimal types are permitted when:

      • The destination has equal or greater decimal precision and
      • The integer portion can still be represented without overflow.
  3. No precision loss:

    • Decimal precision cannot be dropped implicitly. For example, decimal8 → decimal2 is disallowed.
    • Integer → float or float → decimal coercions are always explicit.
  4. Const folding exemption:

    • The compiler may allow implicit coercion for constant literals where the value is statically known to fit.

      • Example: const x: u8 = 1; is valid even if 1 was typed as a decimal128 constant literal.

Explicit Coercion (! operator)

All other numeric conversions must use the ! operator to indicate forceful coercion.

Rules:

Example:

const x: decimal128 = 123.456;
const y: u8 = x!; // y == 123

Semantics:

Example with sum types:

handle(n: u8 | decimal128)
{
	const byte: u8 = n!; // Always legal. Will yield 0 if `n` is decimal128 with no integer part.
}

This model avoids surprises, ensures precision is never lost silently, and eliminates the refactor tax of source-side casts, which is especially beneficial for LLM-based generation and long-term maintainability.

3.2 Semantics of the ! Operator

The ! operator in Typical is a forceful numeric coercion directive. It is used to convert between numeric types when implicit conversion is disallowed due to potential precision loss, overflow, or cross-category conversion.

Purpose


Semantics

const result: u32 = someDecimal!; // NOT: const coerced = decimal!; const result: u32 = coerced;

Coercion Behavior

By default, ! performs truncate-down coercion (toward zero):

Source → Target Coercion Behavior
decimal → int Truncate fractional part (e.g., 12.9 → 12)
int → decimal Embed as whole number (e.g., 7 → 7.000000)
float → decimal Truncate fractional part; no rounding
decimal → float Convert decimal exactly (if possible) or truncate
decimalA → decimalB Truncate if precision loss occurs
int → int (narrowing) Wrap/truncate as needed (e.g., 300 → u8 becomes 44)
Any → Any No runtime check; coercion always succeeds

Note: Rounding modes (e.g., round-half-up, floor, ceil) are not currently part of the spec.


Applicability


Examples

const a: decimal32 = 12.999;
const b: u8 = a!;         // b == 12

const f: f64 = 3.14;
const d: decimal64 = f!;  // d == 3.000000...

const mixed: u8 | decimal128 = getInput();
const out: u8 = mixed!;   // Succeeds regardless of variant; decimals truncated to 0

Rationale

3.3 Destination-Side Coercion with !

The ! operator in Typical is strictly destination-side. This design choice emphasizes assignment-site control and minimizes refactor friction, while aligning with Typical’s philosophy of ergonomic correctness without silent footguns.


Rule Summary


Syntax Form

const destination: TargetType = sourceExpression!;

This form is universal for numeric coercion across the language. Examples:

const a: decimal64 = 123.456;
const b: u8 = a!;              // decimal64 → u8, truncates to 123

const value: f64 | decimal128 = getNumeric();
const asFloat: f64 = value!;   // Either type is coerced into f64

const weird: u32 = 999999999999n!;

Motivation

✅ Refactor-Resilient

Typical intentionally places the coercion at the point of type enforcement, rather than the origin of the value, so that changing the destination type doesn’t require updating all upstream code. This avoids the “refactor shrapnel” common in source-side casting models:

// BAD: Source-cast is brittle
const a = (x as decimal64);
...
const y: f64 = a; // Needs manual re-cast if `a` changes

// GOOD: Destination-side cast
const y: f64 = x!; // Only this line depends on the target type

✅ Sum-Type Friendly

In union types, the actual runtime type may not be known. The destination-directed model allows coercion even when the input type is ambiguous at the call site:

parse(n: u8 | decimal128)
{
	const byte: u8 = n!; // Always legal; decimals truncate to 0
}

✅ Inference-Friendly

This model cooperates with type inference and avoids needing complex overloads or nested conditionals when numeric type widths vary across a function or struct.

✅ LLM-Optimized

LLMs (and programmers) benefit from having a single, consistent coercion site. Since x = y! is always valid (as long as x is a number), it’s easier to generate and refactor code with minimal ambiguity.


Invalid Usage Patterns

The following are illegal:

// ❌ Source-side coercion
const coerced = someDecimal!; // Error: no known target type for coercion

// ❌ Ambiguous coercion site
const result = (a! + b!); // Error: `!` must occur in a coercible assignment context

Note: Arithmetic operations between differing numeric types must be resolved by coercion to a common type, but coercion via ! can only be applied at an explicit assignment or parameter site, not mid-expression.

4. Operator Support and Semantics

This section defines the behavior of operators for Typical’s decimal types across the three categories:

Operator support is only included where semantics are well-defined and non-surprising. If an operator is omitted or restricted, it is due to ambiguity, low utility, or safety constraints.


4.1 Arithmetic Operators

Operator decimalN dMxN decimal Notes
+ Standard decimal addition. Overflow behavior defined per backend.
- Subtraction. Negative values permitted.
* Multiplication. Fixed decimal scale preserved.
/ NaN-unsafe: division by 0 triggers runtime error.
/? NaN-safe: division by 0 yields null.
\ Integer division. Truncates toward zero.
\? Integer division with null on divide-by-zero.
% Modulo. NaN-unsafe.
%? Modulo with null on divide-by-zero.
** ✅ (see below) ✅ (see below) ✅ (see below) Base and exponent must be well-typed.

** Notes:


4.2 Increment / Decrement Operators

Operator decimalN dMxN decimal Notes
++ ✅ (value += 1) ✅ (value += 1) ✅ (value += 1) Always means +1, never “unit-scale”.
-- ✅ (value -= 1) ✅ (value -= 1) ✅ (value -= 1) Disallowed only if result overflows backing type.

✅ Decimals increment and decrement by the literal value 1, not the smallest unit of the type. This preserves consistency across numeric families and prevents sum-type ambiguity.


4.3 Assignment Operators

All assignment operators that correspond to supported arithmetic or logical operations are available.

Operator decimalN dMxN decimal Notes
= Standard assignment. No implicit coercion.
+= Adds and reassigns.
-= Subtracts and reassigns.
*= Multiplies and reassigns.
/= Division (NaN-unsafe).
%= Modulo (NaN-unsafe).
**= Exponentiation. Same constraints as **.

ISSUE: Rewrite this so that it says that all math operators are available for all decimal types.

Shift (<<=, >>=, >>>=) and bitwise assignment operators (&=, |=, ^=) are not defined on any decimal type. These are restricted to integer types only. This technically breaks strict behavioral equivalence with JavaScript (and TypeScript), because JavaScript allows bitwise operations on float64 values, although there is a silent conversion to 32-bit integers when this is used. This decision is subject to change before the specification has finalized.


4.4 Comparison Operators

Operator decimalN dMxN decimal Notes
=== Identity comparison (same value + type).
!== Identity inequality.
== Loose value equality. Cross-type comparison coerces to widest type.
!= Loose inequality.
< / <= / > / >= Cross-type coercion uses widest precision between operands.

⚠️ All cross-type comparisons coerce the narrower operand into the format of the wider one before evaluating.


4.5 Logical Operators

Operator decimalN dMxN decimal Notes
&& Boolean coercion: 0 is falsy, non-zero is truthy.
` `
! Boolean negation. !0 → true, !nonzero → false.

4.6 Bitwise Operators

Not defined on any decimal types.


4.7 NaN-Safety Notes

Typical does not implement NaN. Instead, all dangerous operations are bifurcated into:

This applies to:

Use / when failure is unrecoverable, /? when you want a nullable fallback.

5. Type Relationships

This section defines the coercion rules, equivalence, and subtype relations between decimal types (decimalN, dMxN, decimal) and other numeric types (uN, iN, fN). It also outlines rules for implicit widening, explicit narrowing, and sum-type interactions.


5.1 Implicit Widening Rules

Typical allows safe, precision-preserving implicit conversions from narrower numeric types to wider ones.

Allowed implicit widenings:

From To
u8 → decimalN
u32 → d32xN
u64 → d64xN
uN → decimal
d32xN → decimalN
dMxN → decimal
decimalN → decimal

⚠️ Implicit conversions must be lossless in both value and precision.

No implicit narrowing is permitted. All narrowing is explicit and must use !.

The Typical LSP will generate a meaningful error here to suggest that the coercion operator ! be used to avoid the error. It will also offer a quick-fix to perform the insertion automatically.


5.2 Explicit Narrowing Rules (!)

When converting from a wider to a narrower type, the ! operator must be used. This applies to:

const a: decimal64 = 42.75;
const b: u8 = a!;  // becomes 42

Narrowing behavior:


5.3 Decimal-to-Decimal Conversions

All conversions between decimal types are explicit unless they meet implicit widening criteria.

From To Allowed Implicitly
d32x2 d64x2
d32x2 decimal2
decimal2 decimal
d64x4 decimal4
decimal2 d32x2 ❌ (requires !)
decimal decimal2 ❌ (requires !)
decimal d64xN ❌ (requires !)

Key rule: A smaller backing size (bit width) or lower scale never implicitly accepts a larger one.


5.4 Mixed-Type Comparison Coercion

In binary comparisons between differing numeric types, the wider or more expressive type dominates:

const a: decimal64 = 42.5;
const b: u8 = 42;

a == b   // b is promoted to decimal64

Promotion follows this hierarchy:

u8 < u32 < u64 < u128 < d32xN < d64xN < decimalN < decimal

If two types are equal in width but differ in precision (e.g. d64x2 vs d64x6), the one with greater scale is considered wider for comparison purposes.


5.5 Sum Types

All decimal types can participate in sum types alongside other decimals or numeric types. Coercion rules within branches follow standard ! behavior.

foo(val: u8 | decimal64)
{
	const x: u8 = val!; // force-coerces regardless of which type val is
}

This approach ensures:


5.6 Equivalence and Identity

6. Runtime Semantics

This section defines how decimal types behave at runtime: how they are represented in memory, how they interact with the runtime type system, and what guarantees they provide in typical execution environments. It also includes specifics on overflow, arithmetic, and type introspection.


6.1 Backed Representations

All decimal types are implemented using fixed-width integers, with a compile-time known scale (number of digits after the decimal point).

6.1.1 decimalN

6.1.2 d32xN, d64xN

6.1.3 decimal


6.2 Overflow Behavior

Decimal types do not trap on overflow. All arithmetic follows wraparound semantics, identical to integers.

For dynamic decimals (decimal), wraparound may degrade to a panic in debug mode, but overflow is allowed in release unless explicitly trapped.


6.3 NaN and Divide-by-Zero

Decimal types are NaN-safe.

This design avoids silent propagation of invalid numbers and aligns with explicit nullability elsewhere in the type system.


6.4 Boolean Coercion

All decimal types coerce to boolean in the same way integers do:

This applies to all decimal categories (decimalN, dMxN, decimal), enabling logical expressions like:

const x: decimal64 = 0.0;
if (!x) {
	// treated as falsy
}

6.5 Increment and Decrement

++ and --

let x: decimal4 = 2.3400;
x++; // becomes 3.3400

This preserves consistency with user intuition and makes ++ predictable in sum types.

This creates a knowingly awkward situation where the ++ and -- operations on a decimal128 are effectively a no-op. This is because the decimal128 can only store numbers between 0 and 1. However, this needs to be allowed because the decimal128 could be part of a sum type. If however the ++ or -- operators are used on a standalone decimal128, the compiler could generate a warning that the operation is useless, with an explaination as to why.


6.6 Type Introspection

Decimal types expose their properties to the type system:

type decimal4 = Decimal<4, 128>;
type d32x2 = Decimal<2, 32>;

This enables:


6.7 Arithmetic Semantics

All arithmetic is integer-backed, scale-preserving, and does not degrade to float logic.

Example:

const x: decimal3 = 2.500;
const y = x ** 3; // = 15.625

6.8 Runtime Cost Model

Type Relative Perf Notes
d32xN 🟢 Fastest Fits in 32-bit register, SIMD-friendly
d64xN 🟢 Fast Scalar operations on common CPUs
decimalN 🟡 Medium 128-bit ops, slightly heavier
decimal 🔴 Slow Allocations, dynamic size, GC pressure

Users are encouraged to use dMxN or decimalN where performance matters, and decimal only when flexibility is required.

7. Operator Semantics

This section defines the operator behavior for all decimal categories in Typical: decimalN, d32xN, d64xN, and decimal. Operators behave predictably and safely, with a strict avoidance of silent failure modes (such as NaN). When operations result in undefined or invalid behavior (e.g., divide-by-zero), a bomb is produced rather than a runtime error or a silent NaN.


7.1 General Rules


7.2 Supported Operators and Behavior Matrix

Operator Meaning decimalN d32xN/d64xN decimal Notes
+ Addition Scale-preserving
- Subtraction Scale-preserving
* Multiplication Scale is preserved in result
/ Division 💣 💣 💣 Bomb on divide-by-zero
/? Safe division Returns null on divide-by-zero
\ Integer divide 💣 💣 💣 Same as /, but truncates result
\? Safe integer divide Returns null on divide-by-zero
% Modulo 💣 💣 💣 Bomb on mod-zero
%? Safe modulo Returns null on mod-zero
** Exponentiation Exponent must be an integer
++ Increment Adds 1 (not 0.1)
-- Decrement Subtracts 1

Note: Division and modulo always bomb on zero unless using their safe (?) variants. The silencing operator ! can be used to silence the bomb.


7.3 Assignment Operators

All compound assignment operators are supported where applicable.

Operator Meaning Supported Notes
+= Add & assign In-place addition
-= Subtract & assign
*= Multiply & assign
/= Divide & assign 💣 Bomb on divide-by-zero
%= Modulo & assign 💣 Bomb on mod-zero
**= Power & assign Integer exponents only

7.4 Comparison Operators

All comparisons between decimals and/or other number types are allowed, with the following rules:

Operator Semantics
=== Identity comparison (value + type + scale)
!== Identity negation
== Value comparison (type coercion allowed)
!= Value inequality
<, <=, >, >= Standard numeric comparison after coercion

Example: 1.0000 == 1.00 is true, but the following is false:

const a: decimal4 = 1.0000;
const b: decimal2 = 1.0;
console.log(a === b); // Prints "false"

7.5 Logical Operators

Logical operations follow boolean coercion semantics:

Operator Meaning
&& Logical AND (coerces to bool)
`
! Logical NOT (coerces to bool)

Rules:


7.6 Bitwise Operators

Bitwise operations are not supported on decimal types.

Bitwise operators are omitted intentionally. There is no meaningful use case for applying them to decimal types, and doing so introduces semantic ambiguity, especially in mixed-type contexts.


7.7 Result of Bomb-Producing Operations

Bombs are values which represent unresolvable errors in expression semantics (e.g. divide-by-zero), without immediately halting execution.


7.8 Examples

const x: decimal2 = 10.00;
const y: decimal2 = 0.00;

const z1 = x / y;   // 💣 bomb
const z2 = x /? y;  // null

const a = decimal3(1.000);
const b = decimal2(1.00);
const same = a == b;   // true
const notSame = a === b; // false

if (decimal64(0.0)) {
	// will not enter
}

8. Constants, Literals, and Formatting

This section defines the syntax, resolution behavior, and formatting rules for numeric constants and literals, with special attention to decimal type interactions. The goal is to support ergonomic literal syntax for common use cases, while maintaining full control over scale, precision, and numeric category when needed.


8.1 Default Literal Resolution

Literals without a suffix or annotation resolve to the most general numeric types:

Literal Default Type
1 int
1.0 f64
1.0f f32
1.0d decimal

Literals with decimals but no suffix are not decimals by default. They are floating-point values unless explicitly marked or coerced.

const x = 1.0;    // x: f64
const y = 1.0f;   // y: f32
const z = 1.0d;   // z: decimal

8.2 Decimal Literal Resolution with as

To bind a literal directly to a precision-specific decimal type, use the as keyword.

const a = 1.25 as decimal4;  // a: decimal4
const b = 2.00 as d64x2;     // b: d64x2
const c = 3.14159 as d32x5;  // c: d32x5

Rules:


8.3 Compiler Behavior on Overprecision

If the literal's precision exceeds the declared type's scale, this is a compile-time error:

const a = 1.2345 as decimal2; // ❌ Error: value has 4 decimal digits, but type only allows 2

This avoids any implicit rounding or truncation during literal binding.


8.4 Formatting Rules (REPL, Logs, Debug)

All decimals are formatted with exact scale fidelity—trailing zeros are preserved to reflect declared precision.

Declaration Printed Format
const x = 1.2 as decimal4 1.2000
const y = 3.0 as d64x1 3.0
const z = 2.100 as d32x3 2.100

Decimal formatting guarantees round-trippability from source to print and back to source again.

In REPL/debug output:

let price = 19.9900 as decimal4;
price: decimal4 = 19.9900

8.5 No Unit-Suffix Literals for Specific Decimals

The following does not compile:

const x = 1.234d64x3; // ❌ Invalid

To use d64x3, use as:

const x = 1.234 as d64x3;

This avoids suffix explosion and keeps decimal type control centralized to as-based explicit binding.


8.6 Design Philosophy