Skip to content

String

The String module gives you Effect-style helpers for working with plain JavaScript strings. There is no wrapper type — a String value is just a string — but the functions are data-last (curried) so they drop straight into pipe and flow, and several of them preserve string literal types at the type level (Capitalize, Trim, Concat).

Operations that can missat, indexOf, match, search, and friends — return an Option instead of the native sentinels (-1, undefined, null), so a “not found” result is a value you handle explicitly rather than a magic number you have to remember to check.

import { pipe, String } from "effect"
// Data-last helpers chain cleanly in a pipe
const tags = pipe(
" TypeScript, Effect, Schema ",
String.trim, // "TypeScript, Effect, Schema"
String.toLowerCase, // "typescript, effect, schema"
String.split(", ") // ["typescript", "effect", "schema"]
)
console.log(tags) // ["typescript", "effect", "schema"]

Because the helpers are curried, the single-argument call (String.split(", ")) returns a function (self: string) => ..., which is exactly what pipe feeds the previous result into. Every helper also has a data-first overload where it makes sense, so String.split("a,b", ",") works too.

A handful of transforms thread the literal type through, so the compiler knows the exact resulting string when the input is a literal:

import { String } from "effect"
const tag = String.capitalize("button") // type: "Button", value: "Button"
const trimmed = String.trim(" hi ") // type: "hi", value: "hi"
// Type-level only, mirroring the runtime functions:
type T1 = String.Concat<"foo", "bar"> // "foobar"
type T2 = String.Trim<" hi "> // "hi"
type T3 = Capitalize<"button"> // built-in TS intrinsic, same idea

Basics: the namespace and the empty string

Section titled “Basics: the namespace and the empty string”

String.String is literally the global String constructor, re-exported so you can reach it without leaving the Effect namespace, and String.empty is the canonical "".

import { String } from "effect"
console.log(String.String === globalThis.String) // true
console.log(String.String(123)) // "123" (native coercion)
console.log(String.empty) // ""
console.log(String.isEmpty(String.empty)) // true

Every public export of the module, grouped by task. All examples are runnable.

The native globalThis.String constructor, re-exported. String.String(value) performs native string coercion; new String.String(value) builds a boxed object (rarely what you want).

import { String } from "effect"
console.log(String.String(true)) // => "true"
console.log(String.String([1, 2])) // => "1,2"

The empty string "", typed as the literal "".

import { String } from "effect"
console.log(String.empty) // => ""

A Refinement<unknown, string> that narrows unknown input to string.

import { String } from "effect"
console.log(String.isString("a")) // => true
console.log(String.isString(1)) // => false

An Order<string> using lexicographic comparison. Returns -1, 0, or 1.

import { String } from "effect"
console.log(String.Order("apple", "banana")) // => -1
console.log(String.Order("banana", "apple")) // => 1
console.log(String.Order("apple", "apple")) // => 0

An Equivalence<string> using strict === equality.

import { String } from "effect"
console.log(String.Equivalence("hello", "hello")) // => true
console.log(String.Equivalence("hello", "world")) // => false

A Reducer<string> that concatenates strings, starting from "". Use it with APIs that consume a Reducer to fold many strings into one.

import { String } from "effect"
console.log(String.ReducerConcat.combineAll(["a", "b", "c"])) // => "abc"
console.log(String.ReducerConcat.combineAll([])) // => ""

Concatenates two strings. The type-level Concat<A, B> produces the joined literal type when both inputs are literals.

import { pipe, String } from "effect"
console.log(String.concat("hello", "world")) // => "helloworld"
console.log(pipe("hello", String.concat("world"))) // => "helloworld"
// Type-level equivalent:
type R = String.Concat<"hello", "world"> // "helloworld"

The JavaScript string length, measured in UTF-16 code units (not user-perceived characters).

import { String } from "effect"
console.log(String.length("abc")) // => 3

Returns true for "". Narrows the type to "".

import { String } from "effect"
console.log(String.isEmpty("")) // => true
console.log(String.isEmpty("a")) // => false

Returns true when the string has at least one code unit.

import { String } from "effect"
console.log(String.isNonEmpty("")) // => false
console.log(String.isNonEmpty("a")) // => true

These thread the literal type through using TypeScript’s intrinsic string types, so toUpperCase("ab") has type "AB".

Uppercases the whole string; result type is Uppercase<S>.

import { pipe, String } from "effect"
console.log(String.toUpperCase("hello")) // => "HELLO"
console.log(pipe("a", String.toUpperCase)) // => "A"

Lowercases the whole string; result type is Lowercase<S>.

import { String } from "effect"
console.log(String.toLowerCase("HELLO")) // => "hello"

Uppercases the first character only; result type is Capitalize<S>.

import { String } from "effect"
console.log(String.capitalize("hello")) // => "Hello"

Lowercases the first character only; result type is Uncapitalize<S>.

import { String } from "effect"
console.log(String.uncapitalize("Hello")) // => "hello"

Lowercases according to a locale (handles cases the locale-agnostic version gets wrong, e.g. Turkish dotted/dotless i).

import { pipe, String } from "effect"
console.log(pipe("İ", String.toLocaleLowerCase("tr"))) // => "i"

Uppercases according to a locale.

import { pipe, String } from "effect"
console.log(pipe("i̇", String.toLocaleUpperCase("lt-LT"))) // => "I"

The runtime trims behave like native methods; the Trim, TrimStart, and TrimEnd type-level helpers compute the trimmed literal at compile time.

Removes whitespace from both ends. Result type is Trim<A>.

import { String } from "effect"
console.log(String.trim(" hello world ")) // => "hello world"
type R = String.Trim<" hi "> // "hi"

Removes leading whitespace. Result type is TrimStart<A>.

import { String } from "effect"
console.log(String.trimStart(" hello world")) // => "hello world"
console.log(String.trimStart(" a ")) // => "a "

Removes trailing whitespace. Result type is TrimEnd<A>.

import { String } from "effect"
console.log(String.trimEnd("hello world ")) // => "hello world"
console.log(String.trimEnd(" a ")) // => " a"

Extracts a section between two indices (negative indices count from the end).

import { pipe, String } from "effect"
console.log(pipe("abcd", String.slice(1, 3))) // => "bc"
console.log(pipe("hello world", String.slice(0, 5))) // => "hello"
console.log(pipe("abcd", String.slice(-2))) // => "cd"

Extracts characters between start and (optional) end. Unlike slice, negative arguments are treated as 0.

import { pipe, String } from "effect"
console.log(pipe("abcd", String.substring(1))) // => "bcd"
console.log(pipe("abcd", String.substring(1, 3))) // => "bc"

The character at a (possibly negative) index, as Option. None when out of bounds — no undefined to forget about.

import { pipe, String } from "effect"
console.log(pipe("abc", String.at(1))) // => Option.some("b")
console.log(pipe("abc", String.at(-1))) // => Option.some("c")
console.log(pipe("abc", String.at(4))) // => Option.none()

The character at a non-negative index, as Option. None when out of bounds.

import { pipe, String } from "effect"
console.log(pipe("abc", String.charAt(1))) // => Option.some("b")
console.log(pipe("abc", String.charAt(4))) // => Option.none()

The UTF-16 code unit at an index, as Option<number>. None instead of NaN when out of bounds.

import { String } from "effect"
console.log(String.charCodeAt("abc", 1)) // => Option.some(98)
console.log(String.charCodeAt("abc", 4)) // => Option.none()

The Unicode code point at an index, as Option<number>. None instead of undefined when out of bounds.

import { pipe, String } from "effect"
console.log(pipe("abc", String.codePointAt(1))) // => Option.some(98)
console.log(pipe("abc", String.codePointAt(10))) // => Option.none()

Keeps the first n characters. Clamps: n <= 0 gives "", oversized n gives the whole string, floats round down.

import { String } from "effect"
console.log(String.takeLeft("Hello World", 5)) // => "Hello"
console.log(String.takeLeft("Hi", 100)) // => "Hi"

Keeps the last n characters, with the same clamping rules as takeLeft.

import { String } from "effect"
console.log(String.takeRight("Hello World", 5)) // => "World"
console.log(String.takeRight("Hi", 0)) // => ""

Predicates (includes, startsWith, endsWith) return plain boolean; the positional searches return Option so a miss is never -1 or null.

true if searchString appears at or after the optional position.

import { pipe, String } from "effect"
console.log(pipe("hello world", String.includes("world"))) // => true
console.log(pipe("hello world", String.includes("foo"))) // => false

true if the string starts with the search string (optionally from a position).

import { pipe, String } from "effect"
console.log(pipe("hello world", String.startsWith("hello"))) // => true
console.log(pipe("hello world", String.startsWith("world"))) // => false

true if the string ends with the search string (optionally up to a position).

import { pipe, String } from "effect"
console.log(pipe("hello world", String.endsWith("world"))) // => true
console.log(pipe("hello world", String.endsWith("hello"))) // => false

Index of the first occurrence, as Option<number>None instead of -1.

import { pipe, String } from "effect"
console.log(pipe("abbbc", String.indexOf("b"))) // => Option.some(1)
console.log(pipe("abbbc", String.indexOf("z"))) // => Option.none()

Index of the last occurrence, as Option<number>None instead of -1.

import { pipe, String } from "effect"
console.log(pipe("abbbc", String.lastIndexOf("b"))) // => Option.some(3)
console.log(pipe("abbbc", String.lastIndexOf("d"))) // => Option.none()

Index of the first match for a string or RegExp, as Option<number>None instead of -1.

import { String } from "effect"
console.log(String.search("ababb", "b")) // => Option.some(1)
console.log(String.search("ababb", /abb/)) // => Option.some(2)
console.log(String.search("ababb", "d")) // => Option.none()

Matches against a pattern, returning Option<RegExpMatchArray>None instead of null when there is no match.

import { Option, pipe, String } from "effect"
const m = pipe("hello", String.match(/l+/))
if (Option.isSome(m)) {
console.log(`${m.value[0]}@${m.value.index}`) // => "ll@2"
}
console.log(Option.isNone(pipe("hello", String.match(/x/)))) // => true

Returns an iterator over all matches for a global RegExp, using native matchAll semantics.

import { pipe, String } from "effect"
const matches = pipe("hello world", String.matchAll(/l/g))
console.log(
Array.from(matches, (match) => `${match[0]}@${match.index}`).join(", ")
) // => "l@2, l@3, l@9"

Locale-aware comparison returning an Ordering (-1, 0, or 1), with optional locales and Intl.CollatorOptions.

import { pipe, String } from "effect"
console.log(pipe("a", String.localeCompare("b"))) // => -1
console.log(pipe("b", String.localeCompare("a"))) // => 1
console.log(pipe("a", String.localeCompare("a"))) // => 0

Replaces the first match (string or non-global RegExp); a global RegExp replaces every match. Follows native String.prototype.replace.

import { pipe, String } from "effect"
console.log(pipe("abc", String.replace("b", "d"))) // => "adc"
console.log(pipe("a-a-a", String.replace("-", "_"))) // => "a_a-a"
console.log(pipe("a-a-a", String.replace(/-/g, "_"))) // => "a_a_a"

Replaces all occurrences of a substring or pattern.

import { pipe, String } from "effect"
console.log(pipe("ababb", String.replaceAll("b", "c"))) // => "acacc"
console.log(pipe("ababb", String.replaceAll(/ba/g, "cc"))) // => "accbb"

Splits into a non-empty array of substrings. Splitting "" yields [""], so the result is always at least one element.

import { pipe, String } from "effect"
console.log(String.split("hello,world", ",")) // => ["hello", "world"]
console.log(pipe("abc", String.split(""))) // => ["a", "b", "c"]
console.log(pipe("", String.split(""))) // => [""]

Repeats the string count times.

import { pipe, String } from "effect"
console.log(pipe("a", String.repeat(5))) // => "aaaaa"
console.log(pipe("ab", String.repeat(3))) // => "ababab"

Pads from the start to maxLength with an optional fill string (default " ").

import { pipe, String } from "effect"
console.log(pipe("a", String.padStart(5))) // => " a"
console.log(pipe("7", String.padStart(3, "0"))) // => "007"

Pads from the end to maxLength with an optional fill string (default " ").

import { pipe, String } from "effect"
console.log(pipe("a", String.padEnd(5))) // => "a "
console.log(pipe("a", String.padEnd(5, "_"))) // => "a____"

Normalizes to a Unicode normalization form: "NFC" (default), "NFD", "NFKC", or "NFKD". Use it before comparing strings that may use combining characters.

import { pipe, String } from "effect"
const str = "ẛ̣"
console.log(pipe(str, String.normalize("NFC")) === str) // => true
console.log(pipe(str, String.normalize("NFKC"))) // => "ṩ"

An iterable over each line, stripping the trailing newline. Handles \n, \r, and \r\n.

import { String } from "effect"
console.log(Array.from(String.linesIterator("hello\nworld\n")))
// => ["hello", "world"]

An iterable over each line, keeping the trailing newline characters.

import { String } from "effect"
console.log(Array.from(String.linesWithSeparators("hello\nworld\n")))
// => ["hello\n", "world\n"]

Strips a leading | margin from every line (after any leading whitespace). Handy for multi-line template literals.

import { String } from "effect"
console.log(String.stripMargin(" |hello\n |world"))
// => "hello\nworld"

Like stripMargin, but you choose the margin character.

import { String } from "effect"
console.log(String.stripMarginWith(" $hello\n $world", "$"))
// => "hello\nworld"

There are two families here. The fixed converters (snakeToCamel, camelToSnake, …) assume their input already follows the named source shape and are cheap, direct transforms. The general converters (camelCase, snakeCase, kebabCase, pascalCase, constantCase, and the configurable noCase) tokenize arbitrary mixed input — spaces, separators, and case boundaries — so they are the right choice for free-form text.

snake_case to camelCase. Assumes snake_case input.

import { String } from "effect"
console.log(String.snakeToCamel("hello_world")) // => "helloWorld"
console.log(String.snakeToCamel("foo_bar_baz")) // => "fooBarBaz"

snake_case to PascalCase.

import { String } from "effect"
console.log(String.snakeToPascal("hello_world")) // => "HelloWorld"
console.log(String.snakeToPascal("foo_bar_baz")) // => "FooBarBaz"

snake_case to kebab-case (swaps _ for -).

import { String } from "effect"
console.log(String.snakeToKebab("hello_world")) // => "hello-world"
console.log(String.snakeToKebab("foo_bar_baz")) // => "foo-bar-baz"

camelCase to snake_case.

import { String } from "effect"
console.log(String.camelToSnake("helloWorld")) // => "hello_world"
console.log(String.camelToSnake("fooBarBaz")) // => "foo_bar_baz"

PascalCase to snake_case.

import { String } from "effect"
console.log(String.pascalToSnake("HelloWorld")) // => "hello_world"
console.log(String.pascalToSnake("FooBarBaz")) // => "foo_bar_baz"

kebab-case to snake_case (swaps - for _).

import { String } from "effect"
console.log(String.kebabToSnake("hello-world")) // => "hello_world"
console.log(String.kebabToSnake("foo-bar-baz")) // => "foo_bar_baz"

The general normalizer the others are built on: splits input into word parts at case boundaries and non-word characters, transforms each part (default toLowerCase), and joins with a delimiter (default " "). Options: splitRegExp, stripRegExp, delimiter, and transform.

import { String } from "effect"
console.log(String.noCase("HelloWorld")) // => "hello world"
console.log(String.noCase("user_profile-ID")) // => "user profile id"
// Custom delimiter + transform → dot.case
console.log(
String.noCase("HelloWorld", {
delimiter: ".",
transform: String.toLowerCase
})
) // => "hello.world"

Normalizes free-form input into lower-initial camelCase.

import { String } from "effect"
console.log(String.camelCase("Hello world")) // => "helloWorld"
console.log(String.camelCase("user_profile_id")) // => "userProfileId"

Normalizes free-form input into upper-initial PascalCase.

import { String } from "effect"
console.log(String.pascalCase("hello world")) // => "HelloWorld"
console.log(String.pascalCase("user-profile-id")) // => "UserProfileId"

Normalizes free-form input into lowercase snake_case.

import { String } from "effect"
console.log(String.snakeCase("Hello World")) // => "hello_world"
console.log(String.snakeCase("userProfileId")) // => "user_profile_id"

Normalizes free-form input into lowercase kebab-case — ideal for slugs.

import { String } from "effect"
console.log(String.kebabCase("User profile ID")) // => "user-profile-id"
console.log(String.kebabCase("helloWorld")) // => "hello-world"

Normalizes free-form input into uppercase, underscore-separated CONSTANT_CASE.

import { String } from "effect"
console.log(String.constantCase("Hello World")) // => "HELLO_WORLD"
console.log(String.constantCase("userProfileId")) // => "USER_PROFILE_ID"

The tiny RegExp module rounds out the string toolkit: the native constructor, a guard, and — most usefully — escape for safely embedding untrusted literal text inside a pattern.

The native globalThis.RegExp constructor, re-exported.

import { RegExp } from "effect"
const pattern = new RegExp.RegExp("hello", "i")
console.log(pattern.test("Hello World")) // => true
console.log(pattern.test("goodbye")) // => false

A guard that narrows unknown input to RegExp.

import { RegExp } from "effect"
console.log(RegExp.isRegExp(/a/)) // => true
console.log(RegExp.isRegExp("a")) // => false

Escapes regular-expression metacharacters in a string so it matches literally. Always run user-supplied text through escape before interpolating it into a pattern — otherwise characters like . or * are interpreted as regex syntax.

import { RegExp } from "effect"
console.log(RegExp.escape("a*b")) // => "a\\*b"
// Build a literal matcher from untrusted input safely
const userInput = "a+b.txt"
const expression = new RegExp.RegExp(`^${RegExp.escape(userInput)}$`)
console.log(expression.test("a+b.txt")) // => true
console.log(expression.test("aaab.txt")) // => false

escape only escapes metacharacters — it does not add anchors, flags, or grouping, so you remain in control of the surrounding pattern.