health.qrisk-inputs-validate@1.0.1
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 };
}