Functional Weave
Code in TypeScript

form.character-count

The GOV.UK character count message for a text box: characters or words left or over a limit, with a threshold.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 32 tests, run in TypeScript, Python and Rust.

What it does

The message under a text box with a limit, worded exactly as the GOV.UK Design System's character count: "You have 12 characters remaining", "You have 1 character remaining", "You have 0 characters remaining", "You have 3 characters too many", "You have 1 word too many". It returns the numbers as well, so a form can mark the box in error when `over`, and the server can refuse the same text with the same count:

characterCount("Hello", 10, "characters", null)
// { count: 5, remaining: 5, over: false, visible: true,
//   message: "You have 5 characters remaining",
//   description: "You can enter up to 10 characters" }

For example

  • characterCount(Hello, 10, characters, —) → count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters under the limit: characters left, plural
  • characterCount(, 200, characters, —) → count 0, remaining 200, over false, visible true, message You have 200 characters remaining, description You can enter up to 200 characters nothing typed yet: the whole limit is left
  • characterCount(abcd, 5, characters, —) → count 4, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 5 characters one left is singular

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function characterCount(text: string, limit: number, unit: CountUnit, threshold: number | null): CharacterCount
textstringwhat has been typed so far, exactly as it is
limitintthe most characters or words allowed, 1 or more
unitCountUnitcount characters (Unicode code points) or words (runs of non-whitespace)
thresholdint?percentage of the limit below which the message is not shown, 0 to 100; null shows it always
returnsCharacterCountthe count, what is left, and GOV.UK's message

The types it declares, generated into your project

export type CountUnit = "characters" | "words";

/** How much has been typed against a limit, and what to tell the person. */
export interface CharacterCount {
  /** characters or words typed */
  readonly count: number;
  /** limit less count; negative when over the limit */
  readonly remaining: number;
  /** more than the limit has been typed */
  readonly over: boolean;
  /** the message should be shown: always when over, else when count reaches the threshold */
  readonly visible: boolean;
  /** "You have 12 characters remaining", "You have 1 word too many" */
  readonly message: string;
  /** the limit said up front, "You can enter up to 200 characters", for a page without JavaScript */
  readonly description: string;
}

Your code names it in one line, in the file that uses it

import { characterCount } from "#fune/form.character-count@^1";
impl/typescript.ts · 59 lines · open · raw
import type { CharacterCount, CountUnit } from "./form_character_count_types.ts";

/**
 * The Unicode White_Space property, spelled out so all three languages agree
 * on what separates words (JavaScript's \s, Python's str.isspace and Rust's
 * char::is_whitespace each draw the line slightly differently).
 */
function isWhitespace(cp: number): boolean {
  return (
    (cp >= 0x09 && cp <= 0x0d) ||
    cp === 0x20 ||
    cp === 0x85 ||
    cp === 0xa0 ||
    cp === 0x1680 ||
    (cp >= 0x2000 && cp <= 0x200a) ||
    cp === 0x2028 ||
    cp === 0x2029 ||
    cp === 0x202f ||
    cp === 0x205f ||
    cp === 0x3000
  );
}

function countWords(text: string): number {
  let words = 0;
  let inWord = false;
  for (const ch of text) {
    if (isWhitespace(ch.codePointAt(0) as number)) {
      inWord = false;
    } else if (!inWord) {
      inWord = true;
      words++;
    }
  }
  return words;
}

/**
 * GOV.UK's character count: how many characters (code points) or words have
 * been typed against a limit, and the sentence to show under the text box.
 */
export function characterCount(text: string, limit: number, unit: CountUnit, threshold: number | null): CharacterCount {
  if (typeof text !== "string") throw new TypeError("text must be a string");
  if (!Number.isInteger(limit) || limit < 1) throw new RangeError(`limit must be a whole number of at least 1, received ${limit}`);
  if (unit !== "characters" && unit !== "words") throw new RangeError(`unit must be characters or words, received ${unit}`);
  if (threshold !== null && (!Number.isInteger(threshold) || threshold < 0 || threshold > 100)) {
    throw new RangeError(`threshold must be a whole number from 0 to 100, received ${threshold}`);
  }
  // Code points, not UTF-16 units: an emoji is one character, as in Python and Rust.
  const count = unit === "words" ? countWords(text) : Array.from(text).length;
  const remaining = limit - count;
  const over = remaining < 0;
  // count >= limit * threshold / 100, kept in whole numbers so 50% of 3 is 1.5, not 1.
  const visible = over || threshold === null || count * 100 >= limit * threshold;
  const one = unit === "words" ? "word" : "character";
  const noun = (n: number) => (n === 1 ? one : unit);
  const message = over ? `You have ${-remaining} ${noun(-remaining)} too many` : `You have ${remaining} ${noun(remaining)} remaining`;
  return { count, remaining, over, visible, message, description: `You can enter up to ${limit} ${noun(limit)}` };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add form.character-count
Download for TypeScript form.character-count-1.0.1-typescript.fune · 18,913 bytes sha256 fbe32bb29b89707378ac0c7e616372ff9bb0a35660357bc95a8e1e1253cf8fc6

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./form.character-count-1.0.1-typescript.fune, or fetch it from a terminal with fune pull form.character-count@1.0.1:typescript.

The whole function, every language, is one file too: form.character-count-1.0.1.fune, 25,373 bytes, sha256 09734b67f0d2f813a074ca016c5da79fa9a3dd8e621c10f984d1144a87919df2. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before form.character-count

after — your function gets the result and the arguments, and returns the final result.

// fune: after form.character-count

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show form.character-count --steps.

// fune: step form.character-count after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
under the limit: characters left, plural Hello, 10, characters, — → count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters
nothing typed yet: the whole limit is left , 200, characters, — → count 0, remaining 200, over false, visible true, message You have 200 characters remaining, description You can enter up to 200 characters
one left is singular abcd, 5, characters, — → count 4, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 5 characters
exactly at the limit: 0 characters remaining, not over abcde, 5, characters, — → count 5, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 5 characters
one over is singular, and remaining goes negative abcdef, 5, characters, — → count 6, remaining -1, over true, visible true, message You have 1 character too many, description You can enter up to 5 characters
three over abcdefgh, 5, characters, — → count 8, remaining -3, over true, visible true, message You have 3 characters too many, description You can enter up to 5 characters
an emoji is one character, not two UTF-16 units 😀😀😀, 3, characters, — → count 3, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 3 characters
a decomposed accent (e + combining acute) is two code points Café, 5, characters, — → count 5, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 5 characters
spaces and line breaks are characters too a b c, 10, characters, — → count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters
a limit of 1 is said in the singular , 1, characters, — → count 0, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 1 character
Show the other 22 tests
CaseArgumentsExpected
words: runs of non-whitespace The quick brown fox, 10, words, — → count 4, remaining 6, over false, visible true, message You have 6 words remaining, description You can enter up to 10 words
words: leading, trailing and repeated whitespace, tabs and line breaks do not make words one two three , 3, words, — → count 3, remaining 0, over false, visible true, message You have 0 words remaining, description You can enter up to 3 words
one word left is singular one two, 3, words, — → count 2, remaining 1, over false, visible true, message You have 1 word remaining, description You can enter up to 3 words
one word too many a b c d, 3, words, — → count 4, remaining -1, over true, visible true, message You have 1 word too many, description You can enter up to 3 words
three words too many a b c d e f, 3, words, — → count 6, remaining -3, over true, visible true, message You have 3 words too many, description You can enter up to 3 words
only whitespace is no words , 5, words, — → count 0, remaining 5, over false, visible true, message You have 5 words remaining, description You can enter up to 5 words
a no-break space separates words; punctuation does not one two,three, 5, words, — → count 2, remaining 3, over false, visible true, message You have 3 words remaining, description You can enter up to 5 words
below the threshold the message is not shown, but still worked out abcdefghij, 20, characters, 75 → count 10, remaining 10, over false, visible false, message You have 10 characters remaining, description You can enter up to 20 characters
exactly at the threshold it is shown abcdefghijklmno, 20, characters, 75 → count 15, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 20 characters
50% of 3 is 1.5: one character is below it (whole-number division would say 1 and show it) a, 3, characters, 50 → count 1, remaining 2, over false, visible false, message You have 2 characters remaining, description You can enter up to 3 characters
50% of 3 is 1.5: two characters reach it ab, 3, characters, 50 → count 2, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 3 characters
a threshold of 0 shows the message with nothing typed , 10, characters, 0 → count 0, remaining 10, over false, visible true, message You have 10 characters remaining, description You can enter up to 10 characters
a threshold of 100 hides it until the limit abcdefghi, 10, characters, 100 → count 9, remaining 1, over false, visible false, message You have 1 character remaining, description You can enter up to 10 characters
over the limit is always shown, whatever the threshold abcdefghijk, 10, characters, 100 → count 11, remaining -1, over true, visible true, message You have 1 character too many, description You can enter up to 10 characters
the threshold applies to words when counting words a b, 10, words, 50 → count 2, remaining 8, over false, visible false, message You have 8 words remaining, description You can enter up to 10 words
a limit of 0 is refused abc, 0, characters, — → error: limit must be a whole number of at least 1
a negative limit is refused abc, -5, words, — → error: limit must be a whole number of at least 1
a fractional limit is refused abc, 2.5, characters, — → error: limit must be a whole number of at least 1
a threshold over 100 is refused abc, 10, characters, 101 → error: threshold must be a whole number from 0 to 100
a negative threshold is refused abc, 10, characters, -1 → error: threshold must be a whole number from 0 to 100
a fractional threshold is refused abc, 10, characters, 50.5 → error: threshold must be a whole number from 0 to 100
an unknown unit is refused abc, 10, letters, — → error: unit must be characters or words

More from the author

`react.form.textarea` shows it live as the person types. It is written in all three languages because the server has to enforce the same limit: a count the browser and the API disagree on is a form that says "0 remaining" and then rejects the post.

## Decisions

- **Characters are Unicode code points.** An emoji is 1, not the 2 UTF-16 units JavaScript's `length` (and GOV.UK's own script) counts, so all three languages agree. A decomposed accent (`e` plus a combining acute) is 2. Text is counted as given: a browser posts a line break in a textarea as `\r\n`, which is 2 characters, so normalise line breaks before counting on the server if the limit must match the browser's. - **Words are runs of anything but whitespace**, like GOV.UK's `/\S+/g`, with whitespace spelled out as the Unicode White_Space set (tab to carriage return, space, U+0085, no-break space, U+1680, U+2000-U+200A, the line and paragraph separators, U+202F, U+205F, U+3000) so every language splits alike. Punctuation does not split: `two,three` is one word. - **The threshold** is a percentage of the limit below which the message is not shown (`visible` false), as GOV.UK's `threshold` option. The comparison is `count * 100 >= limit * threshold` in whole numbers, so 50% of 3 is 1.5 and one character stays below it. `null` shows the message always; so does being over the limit, whatever the threshold. The message is worked out even when not visible. - **remaining** goes negative when over; `message` says the positive number "too many". - **description** is GOV.UK's `textareaDescriptionText`, "You can enter up to 200 characters" (singular for a limit of 1), for a page before its script runs.

Errors, identical in every language: `limit must be a whole number of at least 1`, `threshold must be a whole number from 0 to 100`, `unit must be characters or words`.

Sources: GOV.UK Design System, "Character count" https://design-system.service.gov.uk/components/character-count/; the wording and the threshold rule from govuk-frontend's `character-count.mjs` (`charactersUnderLimit`, `charactersAtLimit`, `charactersOverLimit`, `wordsUnderLimit`, `wordsAtLimit`, `wordsOverLimit`, `isOverThreshold`) https://github.com/alphagov/govuk-frontend/blob/main/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs.

## Notices

Portions derived from GOV.UK Frontend (https://github.com/alphagov/govuk-frontend), Copyright (c) 2017 Crown Copyright (Government Digital Service), under the MIT License; the full notice is in NOTICE.

1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

PathBytes
NOTICE1,218
README.md3,314
impl/python.py2,699
impl/rust.rs3,479
impl/typescript.ts2,482
vectors.json7,736