Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Standard library

Standard library

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:

example
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.

example
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:

example
application {}locales {    default: Locale.en_gb    supported: [Locale.en_gb, Locale.sv_se]}
example
type ProductName = Text {    min_length: 1    max_length: 60}
example
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)}
example
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's GB, a Zone's IANA name, a Locale's BCP 47 tag, a FileType's media type (its variants are png, jpeg, webp, pdf, csv, tsv and json), and a Weekday's own name (monday), which has no other code. Its format is its CLDR name where it has one.
  • An Instant ends in Z.
  • A ZonedTime is RFC 9557 (2026-10-25T09:30:00+00:00[Europe/London]), and is read only with the offset its zone has then.
  • An InstantRange is ISO 8601's interval, <start>/<end>, its start not after its end (start_not_after_end).
  • A Duration is 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, Uuid and FileType as their canonical text.
  • A Weekday as CLDR's stand-alone weekday name.
  • A Locale as CLDR's display name ("British English").
  • A Zone as CLDR's generic location format ("UK Time").
  • A Duration in CLDR's long unit names, joined as the locale joins a list of units ("1 hour, 30 minutes").
  • An InstantRange as 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:

example
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}
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.