Financial applications demand more from your type system than most domains. A price is not just a number — it has precision constraints, currency context, and formatting rules. An order is not just an object — it is a state machine with defined transitions. TypeScript gives you the tools to model these constraints at compile time. Here is how to use them.
Branded types for domain primitives
A price and a quantity are both numbers, but passing a quantity where a price is expected is a bug. TypeScript's structural typing allows this silently. Branded types prevent it:
type Brand<T, B extends string> = T & { __brand: B };
type Price = Brand<number, "Price">;
type Quantity = Brand<number, "Quantity">;
type Percentage = Brand<number, "Percentage">;
function createPrice(value: number): Price {
return value as Price;
}
function calculateNotional(price: Price, quantity: Quantity): number {
return price * quantity; // Both are numbers at runtime
}
// Compile error — cannot pass a Quantity where a Price is expected
const qty = createQuantity(10);
const notional = calculateNotional(qty, qty); // Type error
The __brand property never exists at runtime — it is a phantom type that only exists in the type system. Zero runtime cost, strong compile-time safety.
Discriminated unions for order states
An order's lifecycle has distinct states with different associated data. Model each state explicitly:
type Order =
| {
status: "pending";
clientOrderId: string;
symbol: string;
side: "buy" | "sell";
price: Price;
quantity: Quantity;
}
| {
status: "acknowledged";
clientOrderId: string;
exchangeOrderId: string;
symbol: string;
side: "buy" | "sell";
price: Price;
quantity: Quantity;
}
| {
status: "partial";
clientOrderId: string;
exchangeOrderId: string;
symbol: string;
side: "buy" | "sell";
price: Price;
quantity: Quantity;
filledQuantity: Quantity;
avgFillPrice: Price;
}
| {
status: "filled";
clientOrderId: string;
exchangeOrderId: string;
symbol: string;
side: "buy" | "sell";
filledQuantity: Quantity;
avgFillPrice: Price;
}
| {
status: "cancelled";
clientOrderId: string;
exchangeOrderId: string;
reason?: string;
}
| {
status: "rejected";
clientOrderId: string;
reason: string;
};
Now TypeScript enforces that you can only access filledQuantity when the order is in a partial or filled state. A switch on status gives exhaustive checking:
function getDisplayQuantity(order: Order): string {
switch (order.status) {
case "pending":
case "acknowledged":
return formatQuantity(order.quantity);
case "partial":
return `${formatQuantity(order.filledQuantity)} / ${formatQuantity(order.quantity)}`;
case "filled":
return formatQuantity(order.filledQuantity);
case "cancelled":
case "rejected":
return "—";
}
}
If you add a new status later, the compiler will flag every switch that does not handle it.
Instrument configuration types
Each tradable instrument has its own precision rules, lot sizes, and constraints. Type these explicitly:
interface Instrument {
symbol: string;
baseAsset: string;
quoteAsset: string;
pricePrecision: number;
quantityPrecision: number;
minQuantity: Quantity;
maxQuantity: Quantity;
tickSize: Price; // Minimum price increment
lotSize: Quantity; // Minimum quantity increment
status: "trading" | "halted" | "delisted";
}
Use these constraints in your order form validation:
function validateOrderPrice(price: number, instrument: Instrument): string | null {
const remainder = price % (instrument.tickSize as number);
if (remainder !== 0) {
return `Price must be a multiple of ${instrument.tickSize}`;
}
return null;
}
The floating point problem
JavaScript uses IEEE 754 double-precision floats. This means:
0.1 + 0.2 === 0.3; // false — it is 0.30000000000000004
In a trading application, this is not an academic concern. A displayed price of 0.30000000000000004 destroys user trust. A quantity calculation that is off by a fraction can cause order rejections.
Strategies:
- Integer arithmetic — Store prices and quantities as integers in their smallest unit (satoshis for BTC, cents for USD). Multiply by the precision factor on input, divide on display.
// Store as integer: 1.23 BTC = 123000000 satoshis
const priceSatoshis = Math.round(price * 1e8);
-
String-based precision — Use
toFixed()for display andparseFloat()only at the boundary. Never compare floats directly. -
Decimal libraries — For high-precision requirements, use
decimal.jsorbig.js. The runtime cost is higher but the correctness guarantee is worth it for P&L calculations.
Generic data grid types
Trading blotters (order lists, trade history, positions) share a common pattern: a typed data grid. Define a generic column configuration:
interface Column<T> {
key: keyof T;
header: string;
width: number;
align?: "left" | "right";
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
// Usage
const orderColumns: Column<Order>[] = [
{ key: "symbol", header: "Symbol", width: 100 },
{ key: "side", header: "Side", width: 60, render: (v) =>
<span className={v === "buy" ? "text-positive" : "text-negative"}>{v}</span>
},
{ key: "status", header: "Status", width: 80 },
];
The generic constraint ensures that key must be a valid property of the row type, and render receives correctly typed values.
Utility types for API responses
Exchange APIs return data in various shapes. Define transformation types:
// Raw API response
interface RawOrderResponse {
orderId: string;
symbol: string;
side: "BUY" | "SELL";
price: string; // API returns strings
origQty: string;
executedQty: string;
status: string;
}
// Normalised internal type
function normaliseOrder(raw: RawOrderResponse): Order {
const quantity = createQuantity(parseFloat(raw.origQty));
const filledQuantity = createQuantity(parseFloat(raw.executedQty));
// Transform API status strings to your discriminated union
// ...
}
Keep the API response types separate from your internal domain types. The translation layer is where you catch inconsistencies between exchanges.
Key principles
- Make invalid states unrepresentable — If an order cannot have a fill price when it is pending, the type should not allow it
- Brand your domain primitives — Prices, quantities, and percentages should not be interchangeable
- Never trust floating point for display — Always format through a precision-aware function
- Type your API boundary — Raw response types and internal domain types should be distinct
- Use generics for shared patterns — Grids, formatters, and validators all benefit from generic typing