Physician Development 11 min read

TypeScript: Because a Misplaced Decimal Can Hurt a Patient

Clinical software cannot afford vague inputs. TypeScript gives physician-developers safer contracts, clearer data shapes, and fewer silent failures.

Listen to this post

TypeScript: Because a Misplaced Decimal Can Hurt a Patient

0:00
A code editor showing structured clinical data and validation cues in a TypeScript workflow

A few years ago I was reviewing a clinical decision tool built by a resident on a research rotation. The logic was sound. The interface was clean. The tool took a gestational age in weeks, ran it through a growth chart lookup, and returned a percentile.

The problem was that the function accepted a plain number. Nothing in the code enforced that the number was weeks. You could pass it a value in days by accident. You could pass it a BMI. You could pass it a blood pressure reading. The function would run. It would return a number. The number would be wrong. The tool would not tell you.

He had built a calculator that trusted its inputs completely. Clinical software cannot do that.


What TypeScript Actually Is

TypeScript is JavaScript with a type system bolted on.

You write TypeScript in .ts files. A compiler called tsc reads those files and produces regular JavaScript. The browser never sees TypeScript directly. It only sees the compiled output.

That compilation step is where TypeScript earns its place. Before your code runs anywhere, the compiler checks it. It makes sure every function receives the kind of data it expects. It catches the class of errors that JavaScript would let through silently.

TypeScript source (.ts)  →  tsc  →  JavaScript output (.js)  →  browser / Node.js

The compiler does not run at midnight when a nurse is entering a value in your tool. It runs when you build. The goal is that by the time the tool reaches anyone clinical, the obvious type errors are already gone.

Types as Clinical Contracts

In medicine, a number without a unit is dangerous. “The magnesium is 2” means something different depending on whether you are in the United States (mg/dL) or Europe (mmol/L). “Gestational age is 32” means something different if the input expects weeks versus days.

TypeScript cannot enforce units natively the way a physics library would. But it can enforce shape. It can enforce that a function designed to receive a gestational age in weeks refuses to accept a raw number from an untrusted source without an explicit conversion.

This is the concept of a type as a contract. When you define what a function accepts, you are writing a clinical contract into the code. Any caller who violates that contract gets a compiler error before the code runs.

Here is the JavaScript version of a gestational-age input:

// JavaScript: accepts anything
function getTriTrimester(ga) {
  if (ga < 14) return "First";
  if (ga < 28) return "Second";
  return "Third";
}

getTriTrimester(280);   // wrong: days, not weeks. No error.
getTriTrimester("32");  // wrong: string, not number. No error.
getTriTrimester(32.4);  // correct

And the TypeScript version:

// TypeScript: the contract is explicit
type WeeksGA = number;

function getTrimester(ga: WeeksGA): string {
  if (ga < 14) return "First";
  if (ga < 28) return "Second";
  return "Third";
}

getTrimester(280);   // runs, but we can add a range guard
getTrimester("32");  // TypeScript error: Argument of type 'string' is not assignable
getTrimester(32.4);  // correct

The string case is caught before the code ever runs. That is the minimum value TypeScript provides, and it is not small.

The Minimum TypeScript a Physician Needs

TypeScript has a lot of features. You do not need most of them to build clinical tools. Here are the five concepts that cover the work.

Primitive types

let gestationalAgeWeeks: number = 32;
let patientName: string = "Patient A";
let aspirinRecommended: boolean = true;

When you declare a variable with a type, TypeScript will error if you later try to assign a value of a different type to it. This catches reassignment mistakes that are common in long functions.

Interfaces

An interface defines the shape of an object. It is the closest TypeScript equivalent to a clinical data dictionary.

interface RiskInput {
  highRiskFactors: string[];
  moderateRiskFactors: string[];
}

interface RiskResult {
  tier: "high" | "moderate" | "low";
  label: string;
  detail: string;
  aspirinRecommended: boolean;
  aspirinGuidance: string;
}

With these two interfaces defined, the calculateRisk function signature becomes a contract:

function calculateRisk(input: RiskInput): RiskResult {
  // TypeScript now knows exactly what comes in and what must come out
}

If you return an object missing the aspirinRecommended field, the compiler tells you. If you try to access input.moderateFactors instead of input.moderateRiskFactors, the compiler tells you.

Union types and literal types

A union type means “one of these specific values.” For risk tiers:

type RiskTier = "high" | "moderate" | "low";

Now tier cannot be "HIGH" or "medium" or "unclear". It is one of three exact strings. Every place in the code that handles a RiskTier value is forced to handle all three cases.

Type narrowing

TypeScript tracks what you have already checked. Inside an if block that tests whether tier === "high", TypeScript knows tier is "high". This is called narrowing.

if (result.tier === "high") {
  // TypeScript knows result.tier is "high" here
  showAspirinWarning(); // safe to call
}

The as cast (and when not to use it)

When you receive data from outside TypeScript’s view — a form input, a JSON response, user input — you sometimes need to tell the compiler what type you believe it to be.

const value = document.getElementById("ga-input") as HTMLInputElement;
const ga = parseFloat(value.value);

The as cast is a promise to the compiler. You are saying “I know what this is.” If you are wrong, TypeScript cannot help you. Use it at system boundaries only: form inputs, API responses, JSON parsing. Never use it to silence a type error in your own logic.

Setting Up TypeScript

TypeScript requires Node.js and npm. If you do not have them installed, download Node.js from nodejs.org. npm comes with it.

Then install TypeScript globally:

npm install -g typescript

Check that it worked:

tsc --version
# Version 5.x.x

That is the entire setup for this post. Later posts (Astro, especially) will handle TypeScript configuration automatically. For now, we are running tsc directly.


Hands-On: The Preeclampsia Risk Companion, Version 2

We are rewriting preeclampsia-risk-v1.html in TypeScript. The logic is identical. What changes is that every input, intermediate value, and output is typed. The compiler now enforces the clinical contract.

Create a new file called risk-calculator.ts:

// risk-calculator.ts
// Preeclampsia Risk Companion — v2
// TypeScript rewrite of the v1 JavaScript calculator.

// --- Types ---

type RiskTier = "high" | "moderate-multiple" | "moderate-single" | "low";

interface RiskInput {
  highRiskFactors: string[];
  moderateRiskFactors: string[];
}

interface RiskResult {
  tier: RiskTier;
  label: string;
  detail: string;
  aspirinRecommended: boolean;
  aspirinGuidance: string;
}

// --- Logic ---

function calculatePreeclampsiaRisk(input: RiskInput): RiskResult {
  const highCount: number = input.highRiskFactors.length;
  const moderateCount: number = input.moderateRiskFactors.length;

  if (highCount >= 1) {
    return {
      tier: "high",
      label: "High Risk",
      detail: `${highCount} high-risk factor${highCount > 1 ? "s" : ""} identified.`,
      aspirinRecommended: true,
      aspirinGuidance:
        "Low-dose aspirin (81 mg/day) is recommended. Initiate between 12 and 28 weeks, optimally before 16 weeks.",
    };
  }

  if (moderateCount >= 2) {
    return {
      tier: "moderate-multiple",
      label: "Moderate Risk",
      detail: `${moderateCount} moderate-risk factors identified. No single high-risk factor.`,
      aspirinRecommended: true,
      aspirinGuidance:
        "Consider low-dose aspirin (81 mg/day). The combination of multiple moderate-risk factors supports prophylaxis.",
    };
  }

  if (moderateCount === 1) {
    return {
      tier: "moderate-single",
      label: "Moderate Risk (single factor)",
      detail:
        "1 moderate-risk factor identified. Insufficient alone to recommend aspirin prophylaxis per ACOG PB 222.",
      aspirinRecommended: false,
      aspirinGuidance:
        "Aspirin prophylaxis is not routinely recommended for a single moderate-risk factor. Document and monitor.",
    };
  }

  return {
    tier: "low",
    label: "Low Risk",
    detail: "No high-risk or moderate-risk factors identified.",
    aspirinRecommended: false,
    aspirinGuidance:
      "Routine prenatal care. No aspirin prophylaxis indicated based on risk factors alone.",
  };
}

// --- DOM integration ---

function readCheckedValues(name: string): string[] {
  const checked = document.querySelectorAll<HTMLInputElement>(
    `input[name="${name}"]:checked`
  );
  return Array.from(checked).map((el) => el.value);
}

function getCssClass(tier: RiskTier): string {
  if (tier === "high") return "high-risk";
  if (tier === "moderate-multiple" || tier === "moderate-single") return "moderate-risk";
  return "low-risk";
}

function renderResult(result: RiskResult): void {
  const resultEl = document.getElementById("result") as HTMLDivElement;

  resultEl.innerHTML = `
    <div class="result-label">${result.label}</div>
    <div class="result-detail">${result.detail}</div>
    <div class="aspirin-rec">
      <strong>Aspirin guidance:</strong> ${result.aspirinGuidance}
    </div>
  `;

  resultEl.className = getCssClass(result.tier);
  resultEl.style.display = "block";
  resultEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
}

function onCalculate(): void {
  const input: RiskInput = {
    highRiskFactors: readCheckedValues("high"),
    moderateRiskFactors: readCheckedValues("moderate"),
  };

  const result: RiskResult = calculatePreeclampsiaRisk(input);
  renderResult(result);
}

// Attach the event listener once the DOM is ready
document.addEventListener("DOMContentLoaded", () => {
  const btn = document.getElementById("calculate-btn") as HTMLButtonElement;
  btn.addEventListener("click", onCalculate);
});

Compile it:

tsc risk-calculator.ts

This produces risk-calculator.js in the same folder. That file is plain JavaScript — the same thing we wrote manually in Post 1, but generated from typed source. Copy the original HTML from Post 1, update the script tag to point to risk-calculator.js, and it works identically in the browser.

<!-- In preeclampsia-risk-v2.html, replace the inline <script> block with: -->
<script src="risk-calculator.js"></script>

What TypeScript Caught That JavaScript Would Not

Try this in risk-calculator.ts before compiling:

// Deliberately wrong: passing a number where the interface expects string[]
const badInput: RiskInput = {
  highRiskFactors: 3,         // Error: Type 'number' is not assignable to type 'string[]'
  moderateRiskFactors: [],
};

The compiler refuses. The code does not compile. The wrong value never reaches the function.

Now try accessing a property that does not exist:

const result = calculatePreeclampsiaRisk(someInput);
console.log(result.recommendation); // Error: Property 'recommendation' does not exist on type 'RiskResult'

The compiler tells you the property is aspirinGuidance. You do not find out at runtime. You find out before the code ships.

The Separated Logic

Notice that calculatePreeclampsiaRisk in the TypeScript version takes a plain RiskInput and returns a plain RiskResult. It knows nothing about the DOM. It does not read form fields. It does not call document.getElementById.

This separation matters. A function that is pure — takes input, returns output, touches nothing else — is a function you can test. In Post 13 we will write tests for this function. Those tests will not need a browser. They will not need a form. They will just call calculatePreeclampsiaRisk with known inputs and verify the outputs.

You cannot easily test the v1 version. It is tangled with the DOM. The v2 version is not.


What Can Go Wrong

tsc is not found after installing. Close and reopen your terminal. Global npm packages are not available in sessions that were open before the install.

“Cannot find name ‘document’.” TypeScript by default does not include browser types. Add a tsconfig.json to your project folder:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "strict": true,
    "outDir": "./dist"
  }
}

The "DOM" entry in lib tells TypeScript that document, HTMLInputElement, and other browser APIs exist.

“Object is possibly ‘null’.” document.getElementById returns HTMLElement | null because the element might not exist on the page. TypeScript flags this with strict mode on. The fix is the as cast at a system boundary, or a null check:

const btn = document.getElementById("calculate-btn");
if (!btn) throw new Error("Button not found in DOM");
btn.addEventListener("click", onCalculate);

The compiled JavaScript is in a different folder than expected. If you added a tsconfig.json with an outDir, the compiled output goes there. Update your HTML script tag accordingly.


Closing

The resident’s calculator I described at the opening would not have compiled in TypeScript. The function that accepted a raw number where a gestational-age value was expected would have required a type annotation. The annotation would have made the ambiguity visible. The code review would have caught it.

TypeScript does not prevent all bugs. It prevents the bugs that come from data being something other than what the code expects. In clinical software, that class of bug is not abstract. It is the reason a dosing calculator returns a number that looks plausible but is wrong by a factor of seven.

The compiler is a second attending. It does not replace your judgment. It checks your arithmetic before the patient sees it.

In Post 3, we leave the browser entirely and move to Python — the language where clinical data actually lives.


Share X / Twitter Bluesky LinkedIn

Related Posts

A patient-facing clinical interface and browser developer tools open beside a bedside risk calculator
Physician Development

JavaScript: The Language Your Patients Already Run

Every patient portal, tablet consent form, and browser-based clinical tool already runs JavaScript. This is where physician-developers start changing what medicine feels like on a screen.

· 10 min read
javascriptphysician-developerclinical software
A physician-developer standing between a clinical workstation and a coding setup with multiple screens
Physician Development Featured

The Physician-Developer's Stack: Nine Tools. One Doctor. A Workflow That Actually Ships.

A practical 10-part Doctors Who Code series for physicians who want to build and ship clinical tools with the modern web stack, from JavaScript to deployment.

· 6 min read
physician-developerweb developmentclinical software
A clinical software project moving from a local laptop to live screens across browser and phone
Physician Development

Deployment: When Your Code Becomes Clinical Reality

An app on your laptop helps no one. Deployment is the step where physician-developer work finally becomes available to patients, colleagues, and clinics.

· 14 min read
deploymentvercelrailway
Chukwuma Onyeije, MD, FACOG

Chukwuma Onyeije, MD, FACOG

Maternal-Fetal Medicine Specialist

MFM specialist at Atlanta Perinatal Associates. Founder of CodeCraftMD and OpenMFM.org. I write about building physician-owned AI tools, clinical software, and the case for doctors who code.