Functional Weave
Code in TypeScript

health.qrisk-inputs-validate@1.0.0

impl/typescript.ts

5,095 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { validateUkPostcode } from "./validation_uk_postcode.ts";  ← from validation.uk-postcode ^2.0.0 · built alongside by fune
import { type QriskInputError, type QriskInputs, type QriskInputsValidation } from "./health_qrisk_inputs_validate_types.ts";

const SEXES = ["female", "male"];
const ETHNICITIES = ["white-or-not-stated", "indian", "pakistani", "bangladeshi", "chinese", "other-asian", "black-caribbean", "black-african", "other"];
const SMOKING = ["non-smoker", "ex-smoker", "light", "moderate", "heavy"];
const DIABETES = ["none", "type-1", "type-2"];

/** A value of the wrong type is a programming error, not a clinical one: it throws rather than being reported. */
function wholeOrNull(name: string, value: number | null): number | null {
  if (value === null || value === undefined) return null;
  if (typeof value !== "number" || !Number.isInteger(value)) {
    throw new TypeError(`${name} must be a whole number or null, received ${value}`);
  }
  return value;
}

function numberOrNull(name: string, value: number | null): number | null {
  if (value === null || value === undefined) return null;
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new TypeError(`${name} must be a number or null, received ${value}`);
  }
  return value;
}

function textOrNull(name: string, value: string | null): string | null {
  if (value === null || value === undefined) return null;
  if (typeof value !== "string") throw new TypeError(`${name} must be text or null, received ${value}`);
  return value;
}

/**
 * Check a QRISK3 input set against the calculator's published input
 * definitions (qrisk.org form and its validation). It reports every problem,
 * in field order, and never computes a risk.
 */
export function validateQriskInputs(inputs: QriskInputs): QriskInputsValidation {
  const errors: QriskInputError[] = [];
  const add = (field: string, reason: string, message: string) => errors.push({ field, reason, message });

  const age = wholeOrNull("age", inputs.age);
  const sex = textOrNull("sex", inputs.sex);
  const ethnicity = textOrNull("ethnicity", inputs.ethnicity);
  const postcode = textOrNull("postcode", inputs.postcode);
  const smoking = textOrNull("smoking", inputs.smoking);
  const diabetes = textOrNull("diabetes", inputs.diabetes);
  const ratio = numberOrNull("cholesterolHdlRatio", inputs.cholesterolHdlRatio);
  const sbp = wholeOrNull("systolicBp", inputs.systolicBp);
  const sbpSd = numberOrNull("systolicBpSd", inputs.systolicBpSd);
  const height = wholeOrNull("heightCm", inputs.heightCm);
  const weight = wholeOrNull("weightKg", inputs.weightKg);

  const ageMessage = "Age: enter an integer between 25 and 84";
  if (age === null) add("age", "required", ageMessage);
  else if (age < 25 || age > 84) add("age", "out-of-range", ageMessage);

  const choice = (field: string, value: string | null, allowed: string[], label: string) => {
    const message = `${label}: enter one of ${allowed.join(", ")}`;
    if (value === null) add(field, "required", message);
    else if (!allowed.includes(value)) add(field, "unknown-value", message);
  };
  choice("sex", sex, SEXES, "Sex");
  choice("ethnicity", ethnicity, ETHNICITIES, "Ethnicity");
  // Blank means unknown, as on the calculator ("leave blank if unknown").
  if (postcode !== null && postcode.trim() !== "" && !validateUkPostcode(postcode).valid) {
    add("postcode", "bad-postcode", "Postcode: the postcode is not recognised. Please re-enter or leave blank.");
  }
  choice("smoking", smoking, SMOKING, "Smoking status");
  choice("diabetes", diabetes, DIABETES, "Diabetes status");

  if (ratio !== null && (ratio < 1.0 || ratio > 11.0)) {
    add("cholesterolHdlRatio", "out-of-range", "Cholesterol/HDL ratio: either leave blank or enter a number between 1.0 and 11.0.");
  }
  if (sbp !== null && (sbp < 70 || sbp > 210)) {
    add("systolicBp", "out-of-range", "Systolic blood pressure: either leave blank or enter an integer between 70 and 210");
  }
  if (sbpSd !== null && (sbpSd < 0.0 || sbpSd > 40.0)) {
    add("systolicBpSd", "out-of-range", "Standard deviation of SBP: either leave blank or enter a value between 0.0 and 40.0");
  }
  const pair = "You have only entered one of height and weight: please enter both or leave both blank.";
  if (height === null && weight !== null) add("heightCm", "incomplete-pair", pair);
  if (height !== null && (height < 140 || height > 210)) add("heightCm", "out-of-range", "Height: enter an integer between 140 and 210");
  if (weight === null && height !== null) add("weightKg", "incomplete-pair", pair);
  if (weight !== null && (weight < 40 || weight > 180)) add("weightKg", "out-of-range", "Weight: enter an integer between 40 and 180");

  if (inputs.existingCvd === true) {
    add("existingCvd", "not-applicable", "QRISK3 is only valid for people without a diagnosis of coronary heart disease (including angina or heart attack) or stroke/transient ischaemic attack.");
  }
  if (inputs.onStatins === true) {
    add("onStatins", "not-applicable", "QRISK3 is only valid for people who are not on statins.");
  }
  return { valid: errors.length === 0, errors };
}