Functional Weave
Code in TypeScript

auth.normalise-email

Trim an email address and lower-case its domain for storage and lookup, or null if it is not a plausible address.

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

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

What it does

`normaliseEmail(" Alice@Example.COM ")` is `"Alice@example.com"`. Call it on every address before it is stored and before it is looked up, at sign-up, at login and anywhere else, so that one person's address has one spelling in the database. It answers `null` when the trimmed text is not an address that `validation.email` accepts, so a sign-up form or API can report the field in the same step.

**What changes.** Leading and trailing ASCII whitespace (space, tab, line feed, carriage return, form feed, vertical tab) is removed, since pasted addresses often carry it. The domain is lower-cased: domain names are case-insensitive (RFC 4343), so `Example.COM` and `example.com` are the same mailbox host.

For example

  • normaliseEmail( Alice@Example.COM ) → Alice@example.com surrounding spaces are trimmed and the domain lower-cased; the local part keeps its case
  • normaliseEmail(alice@example.com) → alice@example.com an address already in stored form is unchanged
  • normaliseEmail( bob@EXAMPLE.org ) → bob@example.org a tab and a trailing newline from a paste

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 normaliseEmail(value: string): string | null
valuestringthe address as typed into a form
returnsstring?the stored form: trimmed, domain lower-cased, local part as typed; null if not an address

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

import { normaliseEmail } from "#fune/auth.normalise-email@^1";
impl/typescript.ts · 26 lines · open · raw

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

import { isEmail } from "./validation_email.ts";  ← from validation.email ^1.0.0 · built alongside by fune

/** ASCII whitespace only: String.prototype.trim also strips no-break spaces and other Unicode spaces, which Python and Rust would each treat differently. */
function isAsciiSpace(ch: string): boolean {
  return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
}

/**
 * The stored form of an email address: trimmed, domain lower-cased, local
 * part as typed; null when the trimmed text is not a plausible address.
 */
export function normaliseEmail(value: string): string | null {
  if (typeof value !== "string") return null;
  let start = 0;
  let end = value.length;
  while (start < end && isAsciiSpace(value[start])) start++;
  while (end > start && isAsciiSpace(value[end - 1])) end--;
  const trimmed = value.slice(start, end);
  if (!isEmail(trimmed)) return null;
  const at = trimmed.indexOf("@");
  let domain = "";
  for (const ch of trimmed.slice(at + 1)) {
    domain += ch >= "A" && ch <= "Z" ? String.fromCharCode(ch.charCodeAt(0) + 32) : ch;
  }
  return trimmed.slice(0, at + 1) + domain;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, 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 auth.normalise-email
Download for TypeScript auth.normalise-email-1.0.0-typescript.fune · 5,682 bytes sha256 91d22161660ef305594f3b81677607a498a1b461834d6ddf66b97b75ab480788

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./auth.normalise-email-1.0.0-typescript.fune, or fetch it from a terminal with fune pull auth.normalise-email@1.0.0:typescript.

The whole function, every language, is one file too: auth.normalise-email-1.0.0.fune, 7,359 bytes, sha256 40e4714dd4327fd6b0ec3548c5d72ed135e2133a354b5183a9ec1384fcbd6626. 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 auth.normalise-email

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

// fune: after auth.normalise-email

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace validation.email in auth.normalise-email

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 auth.normalise-email --steps.

// fune: step auth.normalise-email 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
surrounding spaces are trimmed and the domain lower-cased; the local part keeps its case Alice@Example.COM → Alice@example.com
an address already in stored form is unchanged alice@example.com → alice@example.com
a tab and a trailing newline from a paste bob@EXAMPLE.org → bob@example.org
dots and a +tag in the local part are kept, not folded Alice.Smith+News@Mail.Example.CO.UK → Alice.Smith+News@mail.example.co.uk
the shortest address accepted a@B.CO → a@b.co
the empty string is not an address → —
only whitespace is not an address → —
text with no at sign not an email → —
a space inside the address is not trimmed away alice @example.com → —
a bare host name is not deliverable from outside alice@localhost → —
Show the other 2 tests
CaseArgumentsExpected
a trailing no-break space is not ASCII whitespace, so the address is refused alice@example.com  → —
two at signs alice@example@com → —

More from the author

**What does not.** The local part, before the `@`, is kept exactly as typed. RFC 5321 section 2.4 says it MAY be case-sensitive and that only the receiving host can decide, so lower-casing it could, in principle, merge two people's accounts. In practice nearly every provider treats it case-insensitively, so `Alice@example.com` and `alice@example.com` will be two different accounts here; an application that wants them merged should lower-case the whole address itself and say so. Dots and `+tags` are also kept (`a.b+news@gmail.com` is not rewritten to `ab@gmail.com`): that folding is one provider's rule, not a property of email.

Non-ASCII whitespace such as a no-break space is not trimmed, and the address is then refused, since `validation.email` accepts ASCII only. Internationalised domains must arrive punycoded (`xn--...`).

Files

PathBytes
README.md1,569
impl/python.py725
impl/rust.rs857
impl/typescript.ts1,099
vectors.json1,370