Text
On this page
The text operations count, search, cut, pad and change the case of
text, and turn other values into text and back. Each is called as
text.<name>, such as text.trim(name).
Text is counted, cut and padded in Unicode code points, as max_length
counts it (Constraints) and as
PostgreSQL's char_length and Python do. A code point isn't always what a
person sees as one character: an emoji with a skin tone is two. No
operation counts those characters (graphemes).
None of these operations takes a PresentationText, the text Jadpo
formats for people, because it is a representation of its own
(Showing dates and times to people).
| Operation | Gives | Fails | SQL form |
|---|---|---|---|
length(value) |
Int, at least 0, in code points |
char_length(x)::bigint |
|
trim(value) |
Text without Unicode white space at either end; never longer |
btrim(x, chars), chars the Unicode White_Space characters |
|
lower(value, locale:), upper(value, locale:) |
Text in Unicode's default case mapping, or a locale's (İ in Turkish) with locale: |
none | |
contains(value, part) |
Bool |
strpos(x, a) > 0 |
|
starts_with(value, prefix), ends_with(value, suffix) |
Bool |
starts_with(x, a); right(x, char_length(a)) = a COLLATE "C" |
|
replace(value, find, with) |
Text with every find replaced, left to right; find never empty |
replace(x, a, b) |
|
split(value, separator) |
List<Text>, at least one item; separator never empty; "" gives [""], and separators side by side give "" between them |
none | |
join(items, separator) |
Text, the items with separator between them |
none | |
slice(value, from, count) |
Text, up to count code points from position from, cut short at the end; both never negative; never longer |
substr(x, a + 1, b) |
|
keep_first(value, n), keep_last(value, n) |
Text, at most n code points from that end; n never negative; never longer |
left(x, n); right(x, n) |
|
drop_first(value, n), drop_last(value, n) |
Text without n code points from that end; never longer |
substr(x, n + 1); CASE WHEN n = 0 THEN x ELSE left(x, -n) END |
|
pad_start(value, length, fill:) |
Text of at least length code points, fill added at the start; never cut; fill: one code point written out |
CASE on char_length(x) with lpad |
|
from(value) |
Text, the value as JSON carries it: an Int's digits, a Decimal with its scale's places, a Uuid in lowercase, true or false, a time in its wire form |
x::text for Int, Decimal, Uuid and Bool; times none |
|
parse(raw, as: T) |
T, any standard type or type built on a representation, from its canonical text (the contract) |
malformed, or the rule it breaks |
none |
format(value, locale:) |
PresentationText for people, any standard type, with the type's own options (unit:, precision:) (the contract) |
none | |
pick(value, locale:) |
Text from a LocalisedText: the locale's entry, else its language's, else the default |
none | |
slug(value) |
Text of lowercase ASCII letters, digits and single -: accents dropped, every other run of characters one -, none at either end; may be empty |
none |
A bakery's product names, through every text operation. It sorts in German and Swedish, so it declares those locales:
locales { default: Locale.en_gb supported: [Locale.en_gb, Locale.de_de, Locale.sv_se]}type ProductName = Text { min_length: 1 max_length: 60}type Caption = Text { max_length: 60}type TagName = Text { min_length: 1 max_length: 20}type Divider = Text { min_length: 1 max_length: 3}type ProductSlug = Text {}type ShelfLabel = Text {}type TagLine = Text {}type OrderReference = Text {}type OrderNumber = Int { min: 0}type PieceCount = Int { min: 0}type Product = Object { name: ProductName tags: List<TagName>}type NameBlank = Failure { kind: FailureKind.invalid_value}/// A product name as it was typed, without the spaces around it. Trimming/// never lengthens text, so `max_length` can't break; it can leave/// nothing, so `min_length` can.function tidy(typed_name: ProductName) fails NameBlank -> ProductName { return attempt ProductName(text.trim(typed_name)) { min_length: NameBlank }}/// The first twelve code points of a name, for a narrow card: never/// longer than the name, so building `Caption` needs no arm.function caption(name: ProductName) -> Caption { return Caption(text.keep_first(name, 12))}/// The name in a web address: lowercase ASCII letters, digits and `-`.function product_slug(name: ProductName) -> ProductSlug { return ProductSlug(text.slug(name))}/// The name for a shelf edge, in capitals by the shopper's own rules.function shelf_label(name: ProductName, shopper: Locale) -> ShelfLabel { return ShelfLabel(text.upper(name, locale: shopper))}/// An order's number as six digits, `42` written `000042`.function order_reference(number: OrderNumber) -> OrderReference { return OrderReference(text.pad_start(text.from(number), 6, fill: "0"))}/// A product's tags on one line.function tag_line(product: Product) -> TagLine { return TagLine(text.join(product.tags, ", "))}/// How many pieces a line holds between separators. A separator is/// never empty, which its type's `min_length` proves.function count_pieces(line: ProductName, spacer: Divider) -> PieceCount { return PieceCount(list.count(text.split(line, spacer)))}/// The products in the order the shopper's alphabet puts their names.function in_name_order(products: List<Product>, shopper: Locale) -> List<Product> { return list.sort_by(products, product -> product.name, locale: shopper)}
Text is counted and cut in code points, so a mark written on its own and each half of an emoji with a skin tone count as one each. Text in another script is cut the same way.
test "text operations give what the page says" { var tidied: ProductName = attempt tidy(" Rye loaf ") assert tidied == "Rye loaf" assert tidy(" ") fails NameBlank assert caption("Seeded sourdough boule") == "Seeded sourd" assert product_slug("Crème Brûlée & Co.") == "creme-brulee-co" assert order_reference(42) == "000042" assert order_reference(1234567) == "1234567" // never cut assert text.length("Rye") == 3 assert text.lower("RYE Loaf") == "rye loaf" assert text.contains("Rye loaf", "loaf") assert text.starts_with("Rye loaf", "Rye") assert text.ends_with("Rye loaf", "loaf") assert not text.ends_with("Rye loaf", "Rye") assert text.replace("salt & pepper & oil", " & ", ", ") == "salt, pepper, oil" assert text.split("flour,sugar,,eggs", ",") == ["flour", "sugar", "", "eggs"] assert text.split("", ",") == [""] assert text.join(["flour", "sugar"], " and ") == "flour and sugar" assert text.slice("sourdough", 4, 3) == "dou" assert text.slice("sourdough", 7, 10) == "gh" // cut short at the end assert text.keep_last("sourdough", 5) == "dough" assert text.drop_first("sourdough", 4) == "dough" assert text.drop_last("sourdough", 5) == "sour" assert text.drop_last("rye", 9) == ""}test "text is counted and cut in code points" { assert text.length("café") == 4 // é as one code point assert text.length("café") == 5 // e and a combining acute: two assert text.drop_last("café", 1) == "cafe" // the mark goes on its own assert text.length("面包") == 2 assert text.keep_first("面包店", 2) == "面包" assert text.length("👍🏽") == 2 // a thumb and a skin tone assert text.keep_first("👍🏽", 1) == "👍" assert text.trim("\t rye\n") == "rye" assert text.upper("straße") == "STRASSE" // longer than it was assert product_slug("面包 Bakery") == "bakery"}test "a locale's case and order" { assert text.upper("istanbul") == "ISTANBUL" assert text.upper("istanbul", locale: Locale.tr_tr) == "İSTANBUL" assert text.lower("DİYARBAKIR", locale: Locale.tr_tr) == "diyarbakır" assert shelf_label("içli köfte", Locale.tr_tr) == "İÇLİ KÖFTE" var oat: Product = Product { name: "Oat" tags: [] } var zopf: Product = Product { name: "Zopf" tags: ["Swiss", "plaited"] } var oland: Product = Product { name: "Ölandsbröd" tags: [] } assert list.sort_by([zopf, oland, oat], product -> product.name) == [oat, zopf, oland] // by code point assert in_name_order([zopf, oland, oat], Locale.de_de) == [oat, oland, zopf] assert in_name_order([zopf, oland, oat], Locale.sv_se) == [oat, zopf, oland] assert tag_line(zopf) == "Swiss, plaited" assert count_pieces("Swiss,plaited", ",") == 2}
Checking text against a pattern
There is no text.matches, and no replacing or capturing by regular
expression. To check whether text looks like a postcode, build the type
whose pattern says what a postcode is:
attempt Postcode(raw) { pattern: ... }
(Creating values). A pattern is
a type's rule, and it is checked in linear time.
Joining text
To join two pieces of text, use +
(Expressions and operators).
text.join is for a list.
join takes a list of any type built on Text, such as
List<TagName>, as list.sum takes a list of any number type.
Collections stay invariant everywhere else.
Cutting and padding
trim, slice and the four cuts (keep_first, keep_last,
drop_first and drop_last) never give text longer than they were
given. The compiler knows this (at_most_length_of), so a type built
from the result keeps the text's max_length without an attempt arm.
It reads the bound from the cut text's type's max_length, from a
literal's length, or from a cut inside a cut. A count written out, as in
keep_first(name, 12), bounds nothing, because at_most_length_of
records the argument only. split's "at least one item" doesn't prove a
min_length either.
lower and upper can make text longer (ß becomes SS), so they
carry no such bound.
keep_first can split what a person sees as one character, such as an
emoji with a skin tone, which is two code points. The preview it cuts is
still valid text.
pad_start's length is any Int. One at or below the text's own
length gives the text back. fill: is required, and is one code point
written as a text literal. A value, or a literal of any other length, is
type.mismatch, as a scale: that isn't written out is.
White space, for trim, is Unicode's White_Space property.
There is no trim_start, trim_end or pad_end, and no way to go
through text character by character. Each job that needs one, such as a
slug, a mask, a preview or a change of case, is an operation instead.
Unicode normalisation happens where text comes in, not in a call (below,
and Language gaps).
Arguments checked while compiling
replace's find and split's separator are never empty
(never_empty). A literal can't be "", and a value's type must have a
min_length of at least 1; otherwise the call is type.argument_empty
(Errors). Counts and positions are never negative, as for
lists.
Text from outside is NFC
The same character can be written in two ways: é as one code point, or
as e followed by a combining accent. Every text a request carries, in
its body, path, query string or headers, is composed to Unicode's NFC as
it is decoded, before its rules are checked. So café typed either way is
the same text, and max_length counts the code points it is kept as
(Constraints,
Text is normalised).
This holds for every type built on Text, an Email included, read
through the route's codecs, a map's keys among them. Text written in code
and text read back from the database are kept as they are.
Case and sorting in a locale
Without locale:, case follows Unicode's full default mapping (ß
becomes SS, and İ becomes i and a combining dot), and
list.sort_by sorts text keys by code point, as queries sort them.
With locale:, case follows that locale's rules (i becomes İ in
Turkish), and list.sort_by orders text keys as the locale's alphabet
does (Ö comes after Z in Swedish, and beside O in German). Both go
through ICU4X's case mapping and collation.
Case mapping takes any Locale, not only the ones locales { }
declares, because its data is Unicode's: one table for every locale.
Sorting uses only the locales locales { } declares. A Locale your
application doesn't support sorts as the supported locale that a header
asking for it would get, else as default:, the same way text for people
is written (Locales). A
deployable carries only its supported locales' collation, so it sorts as
jadpo test does. A project that sorts by a locale and declares none is
type.temporal_locales_required, as one that formats is. Files handed to
the compiler on their own have no application to declare locales, so
they sort by the Locale they are given.
Collation is the locale's default, at CLDR's default strength, so keys
that are equal by its rules keep their order. On list.sort_by,
locale: orders text keys only. With any other key it is
type.temporal_option_unused, whose edit removes it.
Here a shop supports British English and Swedish. A shopper writing
Swedish in Finland gets Swedish order, the supported locale of their
language. One writing Finnish gets British English order, the shop's
default, since it supports no Finnish; in Finnish's own alphabet Ö
would come after Z:
application {}locales { default: Locale.en_gb supported: [Locale.en_gb, Locale.sv_se]}
type ProductName = Text { min_length: 1 max_length: 60}
use shop/types { ProductName }/// The loaves in the order the shopper's alphabet puts their names, of the/// alphabets the shop supports.action in_name_order(loaves: List<ProductName>, shopper: Locale) -> List<ProductName> { return list.sort_by(loaves, loaf -> loaf, locale: shopper)}
use shop/types { ProductName }test "a locale the shop doesn't support sorts in one it does" { var loaves: List<ProductName> = ["Zopf", "Ölandsbröd", "Oat"] assert in_name_order(loaves, Locale.sv_se) == ["Oat", "Zopf", "Ölandsbröd"] assert in_name_order(loaves, Locale.sv_fi) == ["Oat", "Zopf", "Ölandsbröd"] assert in_name_order(loaves, Locale.fi_fi) == ["Oat", "Ölandsbröd", "Zopf"]}
Values as text: from, parse and format
text.from gives a value as JSON carries it
(Built-in types,
Time types). It takes a
standard type with a canonical text, and Int, Decimal and Bool;
anything else is type.mismatch. It takes no enum your application
declares: such an enum has no display label
(Enums), so showing one is a match. It
takes no text either, since a value built on Text already is text
(Email, Url and IpAddress included).
A standard type's canonical text is the one form the contract writes it as:
- A standard enum's code is its variant's entry in its table: a
Country'sGB, aZone's IANA name, aLocale's BCP 47 tag, aFileType's media type (its variants arepng,jpeg,webp,pdf,csv,tsvandjson), and aWeekday's own name (monday), which has no other code. Itsformatis its CLDR name where it has one. - An
Instantends inZ. - A
ZonedTimeis RFC 9557 (2026-10-25T09:30:00+00:00[Europe/London]), and is read only with the offset its zone has then. - An
InstantRangeis ISO 8601's interval,<start>/<end>, its start not after its end (start_not_after_end). - A
Durationis the ISO 8601 form JSON already carries, in hours (PT26H).
text.parse(raw, as: T) reads a value from its canonical text. as: is
a type written out: a standard type, or a type built on one or on Text,
Int, Decimal or Bool. Your application's enum or object is
type.mismatch. Text that isn't the type at all fails malformed, an
Email that isn't an address included. The rules of the type as:
names break by their own names, as a construction's do. Text read as
text is never malformed.
text.format(value, locale:) gives text for people:
Email,Url,IpAddress,UuidandFileTypeas their canonical text.- A
Weekdayas CLDR's stand-alone weekday name. - A
Localeas CLDR's display name ("British English"). - A
Zoneas CLDR's generic location format ("UK Time"). - A
Durationin CLDR's long unit names, joined as the locale joins a list of units ("1 hour, 30 minutes"). - An
InstantRangeas its two ends in its format, joined by–, CLDR's English interval fallback.
zone: and format: are options of the time types only, as their
entries say. A missing format: is type.temporal_format_invalid, and
an option a type doesn't take is type.temporal_option_unused. How a
number is formatted is under Numbers.
Slugs
text.slug decomposes text canonically (NFD) and drops every mark
(Unicode general category M), so é becomes e. Compatibility
characters, such as fi or a full-width A, aren't folded: like any
other character outside ASCII, each becomes -.
text is a reserved name
text is a standard-library namespace, as every standard namespace is.
It can't name a variable, parameter or declaration, so text. always
means the standard library. A field or a named argument may still be
called text:
type Title = Text { min_length: 1 max_length: 80}type PosterLine = Text {}/// A recipe's title, kept for the poster.function poster_line(title: Title) -> PosterLine { var text: PosterLine = PosterLine(title) return text}
error: `text` is a standard-library namespace --> main.jadpo:10:9 |10 | var text: PosterLine = PosterLine(title) | ^^^^ reserved reason: Standard-library namespaces can't name a declaration, domain, library or variable, so `text.` always means the standard library. next: Choose another name than `text` (agent) rule: semantic.standard_namespace_reserved help: https://jadpo.dev/docs/errors/semantic.standard_namespace_reservedcheck failed: 1 error, 1 file
Markdown for agents: /docs/text.md.Built from Jadpo main at a707efb.