Building a Type-Safe Engineering Calculator

Design and implement the core TypeScript types and utility functions behind a type-safe engineering calculator — typed inputs, a discriminated union of operations, generic unit conversion, and a typed calculation history — as a hands-on capstone applying everything covered in this course.

This lesson walks through designing the TypeScript layer of a small but realistic engineering calculator, in the same spirit as EngiLipi's own Calculator Hub: typed inputs validated before use, a discriminated union describing what operation is being performed, a generic helper for converting between units, and a history of past calculations that's genuinely typed rather than an array of loosely-shaped objects.

Modeling calculator operations with a discriminated union

The first design decision is how to represent 'what calculation is being requested.' A discriminated union, tagged by an operation field, is the natural fit here: each variant carries exactly the inputs that operation actually needs, and nothing else — an OhmsLaw operation needs voltage and current (or voltage and resistance, or current and resistance), while a UnitConversion operation needs a value, a from-unit, and a to-unit. Modeling this as one giant interface with every possible field marked optional would let you construct nonsensical combinations, exactly as covered in the best-practices lesson.

A calculate function that switches on the operation tag gets full narrowing for free inside each branch, and an exhaustiveness check (a never-typed default branch) means adding a new calculator later and forgetting to implement its case becomes a compile error instead of a silent runtime gap — a real concern for a hub that's meant to keep growing to dozens of calculators across many engineering branches.

A generic unit conversion helper

Unit conversion is a good candidate for a single generic helper rather than one hand-written function per unit pair, because the actual logic — convert the input to a common base unit, then from that base unit to the target — is identical regardless of which category (length, mass, temperature) is involved. A generic convertUnit<T extends string> function, parameterized by the specific set of unit names valid for a given category, gets autocomplete and compile-time checking that only real unit names for that category can be passed, while still sharing one implementation across every category.

Temperature is worth calling out as the one common exception: unlike length or mass, converting between Celsius, Fahrenheit, and Kelvin isn't a pure multiplication by a conversion factor — it involves an offset — so a 'convert to base unit, multiply by factor' table-driven approach doesn't work unmodified for it and needs its own small conversion function, kept separate from the generic multiplicative converter rather than forced into the same shape.

Typed calculation history and configuration

A calculation history is only useful if it's genuinely typed — an entry needs to record which operation was run, what inputs produced it, and what result came out, in a shape specific enough that rendering the history later doesn't require re-deriving what fields might be present. A CalculationHistoryEntry interface capturing the operation's discriminated-union input alongside its numeric result (and, as the exercise below extends it, a timestamp) keeps the history list itself simple — CalculationHistoryEntry[] — while each entry stays fully informative.

A small CalculatorConfig interface (unit-display preference, decimal precision, whether to auto-save history) is a good home for the handful of user-facing settings a calculator page needs, kept separate from the operation types themselves — config describes how results are presented, not what was calculated, and mixing the two concerns into one type tends to produce exactly the kind of overloaded, hard-to-reason-about interface the best-practices lesson warns against.

Validating input and handling errors safely

Raw form input arrives as strings (or possibly empty strings), never as the number type a calculation function actually needs — so validation is not optional polish, it's the boundary where untrusted UI input becomes a trustworthy typed value, exactly the runtime-validation pattern covered earlier in this course. A parseCalculatorInput helper that returns either a validated number or a descriptive error, rather than silently coercing bad input to NaN and letting it propagate, keeps the actual calculation functions simple: by the time a number reaches them, it's genuinely a valid number.

For calculations with a real domain-specific failure mode — division by zero in Ohm's Law when resistance is zero, a negative value where only positive values are physically meaningful — a small custom error type (mirroring the CalcError pattern used for exactly this purpose in EngiLipi's own calculator hub) lets calling code distinguish 'this input was malformed' from 'this input was well-formed but the physics doesn't allow it,' and display each with an appropriately specific message instead of one generic 'calculation failed.'

Core calculator types

CalculatorOperation is the discriminated union driving both the calculate() function and, unchanged, the shape recorded in each history entry's input field — one type serves both purposes.

type CalculatorOperation =
  | { operation: "ohmsLawVoltage"; current: number; resistance: number }
  | { operation: "ohmsLawResistance"; voltage: number; current: number }
  | { operation: "unitConversion"; value: number; from: string; to: string; category: UnitCategory }
  | { operation: "power"; force: number; velocity: number };

type UnitCategory = "length" | "mass" | "speed";

interface CalculationHistoryEntry {
  id: string;
  input: CalculatorOperation;
  result: number;
  timestamp: number;
}

interface CalculatorConfig {
  precision: number;
  preferredUnitSystem: "metric" | "imperial";
  autoSaveHistory: boolean;
}

class CalcError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "CalcError";
  }
}

Code Examples

convertUnit is generic over the unit-name type T, so passing LengthUnit as T means only "m" | "km" | "ft" | "mi" are valid values for from and to -- a typo like "kilometers" is a compile error, not a silent runtime NaN -- while the exact same function also serves mass, speed, or any other multiplicative unit category by passing a different Record and unit type. Expected result: miles ≈ 3.106855...

function convertUnit<T extends string>(value: number, from: T, to: T, toBase: Record<T, number>): number pushHistory takes the exact CalculatorOperation that was run and its numeric result, and returns the fully-formed entry it created (useful for, say, showing an 'undo last calculation' affordance keyed by the returned id) -- because history's element type is CalculationHistoryEntry rather than a loose object literal, every consumer of history downstream gets full autocomplete and checking on entry.input.operation, entry.result, and entry.timestamp.

Common mistake: Declaring history as any[] or object[] to 'save time' during initial development is exactly the kind of shortcut that defeats the purpose — every place the history is later rendered or filtered loses type checking on its entries and has to re-verify their shape defensively.

type LengthUnit = "m" | "km" | "ft" | "mi";

// factor to convert 1 unit into meters (the base unit for this category)
const LENGTH_TO_METERS: Record<LengthUnit, number> = {
  m: 1,
  km: 1000,
  ft: 0.3048,
  mi: 1609.34,
};

function convertUnit<T extends string>(
  value: number,
  from: T,
  to: T,
  toBase: Record<T, number>
): number {
  const valueInBase = value * toBase[from];
  return valueInBase / toBase[to];
}

const miles = convertUnit<LengthUnit>(5, "km", "mi", LENGTH_TO_METERS);
// 5 km -> meters -> miles, using one shared implementation
let history: CalculationHistoryEntry[] = [];

function pushHistory(
  input: CalculatorOperation,
  result: number
): CalculationHistoryEntry {
  const entry: CalculationHistoryEntry = {
    id: crypto.randomUUID(),
    input,
    result,
    timestamp: Date.now(),
  };
  history = [...history, entry];
  return entry;
}

// Usage after a calculation:
const op: CalculatorOperation = { operation: "ohmsLawVoltage", current: 2, resistance: 6 };
const result = op.current * op.resistance; // 12
pushHistory(op, result);

Data flow through the typed calculator

Every arrow crosses a point where an untyped or loosely-typed value becomes a specifically-typed one -- validation, then the operation union, then the history entry -- rather than passing raw values straight through to the UI.

raw form input (strings)
        |
  [parseCalculatorInput]
  (validation boundary --
   returns number or error)
        |
  CalculatorOperation
  (discriminated union,
   one variant per calc type)
        |
     [calculate()]
  (switch on operation tag,
   narrowed per-branch,
   may throw CalcError)
        |
     numeric result
        |
   [convertUnit<T>]  (optional, if display units differ)
        |
     [pushHistory()]
        |
  CalculationHistoryEntry[]
  (typed history, rendered
   in the UI)

Important Rules

Model calculator operations as a discriminated union, one variant per calculation type, rather than one interface with many optional fields.

Write unit conversion as one generic, table-driven function per multiplicative category, and keep offset-based conversions (temperature) separate rather than forcing them into the same shape.

Give calculation history entries a real interface (including a timestamp and the exact operation that produced them), not a loosely-typed object array.

Validate raw string input into a real number (or a descriptive error) at the boundary, before it ever reaches a calculation function.

Use a domain-specific error type for physically-invalid inputs (division by zero, negative values where only positive is valid) so the UI can show a specific, useful message.

Where this connects to SampleApp

This mirrors the actual architecture behind EngiLipi's Calculator Hub, documented in the project's own implementation notes: a shared CalculatorDefinition/CalculatorInput/CalculatorResult type set, a CalcError class for domain-specific invalid-combination errors, and a centralized unit-conversion utility (UNIT_CATEGORIES) reused across calculators rather than duplicated per-calculator — the same generic, data-driven approach this lesson builds in miniature, which is exactly why the Hub can add new calculators as pure data additions without writing new per-calculator UI code.

function calculate(op: any): number { if (op.operation === "ohmsLawVoltage") return op.current * op.resistance; /*... */ } function calculate(op: CalculatorOperation): number { switch (op.operation) { case "ohmsLawVoltage": return op.current * op.resistance; /*...exhaustive switch... */ } }

Typing the parameter as any silently accepts any shape at all and gives up autocomplete and narrowing inside every branch; typing it as the CalculatorOperation union gets full per-branch narrowing and an exhaustiveness check for free.

[multiple-choice] Why does a table-driven, generic convertUnit function work well for length or mass but need a separate approach for temperature? A. Temperature units don't have names B. Temperature conversion involves an offset, not just multiplication by a factor, unlike length or mass C. Generics don't support numeric types D. convertUnit can't accept more than two unit arguments

B. Temperature conversion involves an offset, not just multiplication by a factor, unlike length or mass — Length and mass conversions are pure scale factors relative to a base unit, which fits a 'multiply to base, divide from base' table nicely; temperature (Celsius/Fahrenheit/Kelvin) requires an additive offset as well, so it needs its own conversion logic.

[true-false] Typing a calculation history array as CalculationHistoryEntry[] instead of any[] only matters for autocomplete convenience, not for actual correctness. A. True B. False

B. False — A properly typed history array is checked at every point it's read, filtered, or rendered — an any[] history would let a malformed entry (missing a field, wrong shape) pass through undetected until it caused a runtime error in the UI.

[multiple-choice] Where should raw string input from a calculator's form fields be converted into validated numbers? A. Inside the calculate() function itself, mixed with the calculation logic B. At a dedicated validation boundary, before the value ever reaches calculate() C. It doesn't need validation since TypeScript checks types automatically at runtime D. In the CalculationHistoryEntry interface

B. At a dedicated validation boundary, before the value ever reaches calculate() — TypeScript's types are erased at runtime and provide no automatic runtime checking, so raw form input (always strings) needs an explicit validation step at the boundary; keeping that separate from calculate() keeps the calculation logic itself simple and trustworthy.

Extend CalculationHistoryEntry so the history can answer 'what did I calculate today?' Add a way to filter the history array down to only entries from the current calendar day, using the entry's timestamp.

function isSameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } function getTodaysEntries( entries: CalculationHistoryEntry[] ): CalculationHistoryEntry[] { const today = new Date(); return entries.filter((entry) => isSameDay(new Date(entry.timestamp), today) ); }

CalculationHistoryEntry already has a numeric timestamp field (from Date.now()); compare each entry's date to today's date using the Date object, not just a raw millisecond difference (which would incorrectly exclude entries from earlier today).