bastianplsfix

Template literal types

A template literal type has the same syntax as a template literal and does the same job one level up: it builds a string type out of other types. Interpolate a union and you get every combination, which is what makes the feature concise, and also how it gets expensive. This entry is the syntax, the four case utilities, the two ceilings, and the honest cost of implementing a string operation twice.

Create programs/template-literal-types.test.ts for this reference and keep it open, starting with the imports:

import { assertEquals, assertStrictEquals } from "@std/assert";
import { assertType, type IsExact } from "@std/testing/types";

Below the imports, add the types and the function the whole page shares; each earns its explanation in its step, and link is the worked example the entry ends on.

type Method = "GET" | "POST" | "DELETE";

type Endpoint = "/users" | "/articles";

type Route = `${Method} ${Endpoint}`;

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;

type Repeat<Length extends number, Item, Acc extends Array<unknown> = []> =
Acc["length"] extends Length ? Acc : Repeat<Length, Item, [...Acc, Item]>;

type PathParams<Path extends string> = Path extends
`${string}:${infer Param}/${infer Rest}` ? Param | PathParams<`/${Rest}`>
: Path extends `${string}:${infer Param}` ? Param
: never;

function link<Path extends string>(
path: Path,
params: Record<PathParams<Path>, string>,
): string {
return path.replace(
/:(\w+)/g,
(_match, name: string) => (params as Record<string, string>)[name],
);
}

Follow the page as you add and revise the runnable examples below it.

every combination, and the refusal prints the union

Deno.test("every combination, and the refusal prints the union", () => {
const route: Route = "POST /articles";

// @ts-expect-error: Type '"PATCH /users"' is not assignable to type '"GET /users" | "GET /articles" | "POST /users" | "POST /articles" | "DELETE /users" | "DELETE /articles"'.
const unsupported: Route = "PATCH /users";

assertStrictEquals(route, "POST /articles");
assertStrictEquals(unsupported, "PATCH /users");
});
Check programs/template-literal-types.test.ts
running 1 test from ./programs/template-literal-types.test.ts
every combination, and the refusal prints the union ... ok (186µs)

ok | 1 passed | 0 failed (1ms)

Six legal strings from two lines, and a typo in any of them is a compile error. The refusal is worth reading rather than skipping: the compiler prints the whole union, which is the easiest way to see what you built, the probe technique the keyof and indexed access page sets out arriving here for free in the shield's comment.

the case utilities are the JavaScript methods

Deno.test("the case utilities are the JavaScript methods", () => {
const shouted: Uppercase<"hello"> = "HELLO";
const quiet: Lowercase<"HELLO"> = "hello";
const capital: Capitalize<"hello"> = "Hello";
const uncapital: Uncapitalize<"HELLO"> = "hELLO";

assertStrictEquals("hello".toUpperCase(), shouted);
assertStrictEquals("HELLO".toLowerCase(), quiet);
assertStrictEquals(
"hello".charAt(0).toUpperCase() + "hello".slice(1),
capital,
);
assertStrictEquals(
"HELLO".charAt(0).toLowerCase() + "HELLO".slice(1),
uncapital,
);
});
the case utilities are the JavaScript methods ... ok (33µs)

Four built-in types, and each assertion pairs one with the JavaScript expression that does the same thing to a value. That pairing is the whole mental model: these are string operations that happen while the code is being checked rather than while it is running, and they are the same operations.

a union goes in, a union comes out

Deno.test("a union goes in, a union comes out", () => {
type Bracketed<T extends string> = `[${T}]`;

assertType<
IsExact<Bracketed<"a" | "b" | "c">, "[a]" | "[b]" | "[c]">
>(true);

// The run-time half is a loop, and the type-level half is not.
assertEquals(
["a", "b", "c"].map((item) => `[${item}]`),
["[a]", "[b]", "[c]"],
);
});
a union goes in, a union comes out ... ok (161µs)

A union goes in and a union comes out, one member per member, with no loop written anywhere, while the run-time half needs the map. This is the same distribution the conditional types page covers, and template literal types have it too.

the product is real, and there are two ceilings

Deno.test("the product is real, and there are two ceilings", () => {
type FourDigits = `${Digit}${Digit}${Digit}${Digit}`;

// @ts-expect-error: ten thousand is fine, a hundred thousand is not
type FiveDigits = `${Digit}${Digit}${Digit}${Digit}${Digit}`;

type Short = Repeat<999, "x">;

// @ts-expect-error: one instantiation too many
type Long = Repeat<1000, "x">;

const fourDigits: FourDigits = "1234";
const shortLength: Short["length"] = 999;

assertStrictEquals(fourDigits, "1234");
assertStrictEquals(shortLength, 999);
});
the product is real, and there are two ceilings ... ok (15µs)

Strip both shields in a copy of the file and the two ceilings introduce themselves:

Check programs/template-literal-types.test.ts
TS2590 [ERROR]: Expression produces a union type that is too complex to represent.
type FiveDigits = `${Digit}${Digit}${Digit}${Digit}${Digit}`;
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at file:///programs/template-literal-types.test.ts:75:21

TS2589 [ERROR]: Type instantiation is excessively deep and possibly infinite.
type Long = Repeat<1000, "x">;
~~~~~~~~~~~~~~~~~
at file:///programs/template-literal-types.test.ts:79:15

Found 2 errors.

error: Type checking failed.

Interpolating one ten-member union four times gives ten thousand string types and compiles; doing it five times asks for a hundred thousand and is refused. The number to remember is not the exact limit but the shape of the arithmetic: combinations multiply, and four is a lot of positions. The other ceiling is different in kind: TS2590 is about the width of a union, and TS2589 is about the depth of a recursion, which is a thousand steps, so Repeat<999, "x"> builds its tuple and Repeat<1000, "x"> does not. One empirical correction to the post this page is built from: it describes the recursion ceiling as lazy, only enforced when something reads the type, but in this toolchain the bare type Long = Repeat<1000, "x"> alias is refused on the spot with nothing using it, which is why the shield sits directly on the declaration.

some primitive types can be interpolated

Deno.test("some primitive types can be interpolated", () => {
type Version = `v${number}.${number}`;

const version: Version = "v1.0";

// @ts-expect-error: Type '"v2.zero"' is not assignable to type '`v${number}.${number}`'.
const words: Version = "v2.zero";

const nothing: `${undefined}` = "undefined";
const nulled: `${null}` = "null";
const flag: `${boolean}` = "true";
const count: `${number}` = "123";
const big: `${bigint}` = "123";
const text: `${string}` = "anything";

// @ts-expect-error: Type 'symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
const unique: `${symbol}` = "nope";

assertStrictEquals(`${undefined}`, nothing);
assertStrictEquals(`${null}`, nulled);
assertEquals([version, words, flag, count, big, text, unique], [
"v1.0",
"v2.zero",
"true",
"123",
"123",
"anything",
"nope",
]);
});
some primitive types can be interpolated ... ok (40µs)

Interpolating number does not give you a union of every numeric string, which would be infinite. It gives you a pattern, and assignability checks the shape, so Version is a subset of string you can annotate with, and the checker tells you "v2.zero" is not one. Six primitives work and one does not: `${undefined}` is the type whose only value is the string "undefined", which is exactly what interpolating undefined does at run time, and the two assertStrictEquals lines check the two levels agree. symbol is refused because a symbol has no string form, and `${aSymbol}` throws a TypeError at run time too, which the symbols page covers: the type system is agreeing with the run time rather than being fussy. `${string}` accepts every string, a slightly odd way of writing string that becomes useful with something on either side of it, as `a${string}` for "starts with a".

a numeric template is a filter for keys

Deno.test("a numeric template is a filter for keys", () => {
type Keys = "0" | "2" | "length" | "toString";

type IndexKeys<T> = Extract<keyof T, `${number}`>;

assertType<IsExact<Extract<Keys, `${number}`>, "0" | "2">>(true);
assertType<IsExact<IndexKeys<["a", "b"]>, "0" | "1">>(true);
assertType<
IsExact<Exclude<"apple" | "apricot" | "banana", `a${string}`>, "banana">
>(true);

// The same prefix filter, at run time.
assertEquals(
["apple", "apricot", "banana"].filter((name) => !name.startsWith("a")),
["banana"],
);
});
a numeric template is a filter for keys ... ok (29µs)

Because `${number}` is a pattern, Extract can use it to keep only the members of a union that match. That is how you get a tuple's indices out of its keyof, which the keyof and indexed access page said needed filtering, and which the tuple types page puts to work. `a${string}` with Exclude is the same move for a prefix. Between them, these two lines cover most of what people actually want from this feature: not building strings, but selecting among strings they already have.

infer inside a template takes a string apart

Deno.test("infer inside a template takes a string apart", () => {
type Semver<Str extends string> = Str extends
`${infer Major}.${infer Minor}.${infer Patch}` ? [Major, Minor, Patch]
: never;

type AsNumber<Str extends string> = Str extends `${infer N extends number}`
? N
: never;

assertType<IsExact<Semver<"1.2.3">, ["1", "2", "3"]>>(true);
assertType<IsExact<Semver<"nightly">, never>>(true);

assertType<IsExact<AsNumber<"123">, 123>>(true);
assertType<IsExact<AsNumber<"-123">, -123>>(true);
assertType<IsExact<AsNumber<"1.0">, number>>(true);
assertType<IsExact<AsNumber<"1e2">, number>>(true);
assertType<IsExact<AsNumber<"oak">, never>>(true);

assertEquals("1.2.3".split("."), ["1", "2", "3"]);
assertStrictEquals(Number("123"), 123);
});
infer inside a template takes a string apart ... ok (59µs)

infer in a template position is pattern matching against a string literal type. The parts come out as string literal types, which is why Semver<"1.2.3"> is ["1", "2", "3"] with quotes rather than numbers. infer N extends number asks for a number instead, the form the conditional types page introduced, and the five results after it are the honest limits: an integer parses to its literal type, negative included, a decimal and an exponent both widen to number, which is the failure mode to watch for, not an error but a silent loss of the literal, and anything else is never.

one character in, two out

Deno.test("one character in, two out", () => {
const sharp: Uppercase<"ß"> = "SS";
const ligature: Uppercase<"fi"> = "FI";
const rainbow: Capitalize<"🌈bow"> = "🌈bow";

assertStrictEquals("ß".toUpperCase(), sharp);
assertStrictEquals("fi".toUpperCase(), ligature);
assertStrictEquals(sharp.length, 2);
assertStrictEquals(rainbow, "🌈bow");
});
one character in, two out ... ok (27µs)

Uppercase<"ß"> is "SS": one character in, two out, at the type level, because the implementation really is toUpperCase() and that is what the method does, and the ligature tells the same story. Capitalize is charAt(0).toUpperCase() + slice(1), so it operates on the first code unit, and the first code unit of "🌈bow" is half a surrogate pair with no uppercase form, so the type comes back unchanged; the text and characters page is the entry for why a rainbow is two units long. None of the four is locale-aware, which matters for exactly one language in practice, since a Turkish dotted uppercase I will not appear: if you need locale rules, you need them at run time, where toLocaleUpperCase lives.

recursion, and the method it agrees with

Match one space, recurse on the rest, stop when the pattern no longer fits: there is no loop at the type level, so recursion is the loop, and this is the shape every string-processing type takes. Split and Join are the same technique with a separator, and each has its method beside it. Then predict that the agreement holds for the empty separator too:

Deno.test("recursion, and the method it agrees with", () => {
type TrimStart<Str extends string> = Str extends ` ${infer Rest}`
? TrimStart<Rest>
: Str;

type TrimEnd<Str extends string> = Str extends `${infer Rest} `
? TrimEnd<Rest>
: Str;

type Trim<Str extends string> = TrimStart<TrimEnd<Str>>;

const trimmed: Trim<" text "> = "text";

assertType<IsExact<TrimStart<" text ">, "text ">>(true);
assertType<IsExact<TrimEnd<" text ">, " text">>(true);
assertStrictEquals(" text ".trim(), trimmed);

type Split<Str extends string, Sep extends string> = Str extends
`${infer Head}${Sep}${infer Tail}` ? [Head, ...Split<Tail, Sep>]
: [Str];

type Join<Parts extends Array<string>, Sep extends string> = Parts extends [
infer Head extends string,
...infer Tail extends Array<string>,
] ? Tail["length"] extends 0 ? Head : `${Head}${Sep}${Join<Tail, Sep>}`
: "";

assertType<IsExact<Split<"a-b-c", "-">, ["a", "b", "c"]>>(true);
assertEquals("a-b-c".split("-"), ["a", "b", "c"]);

assertType<IsExact<Join<["a", "b", "c"], "-">, "a-b-c">>(true);
assertStrictEquals(["a", "b", "c"].join("-"), "a-b-c");

// Two implementations of one algorithm, and they are not the same one.
assertType<IsExact<Split<"oak", "">, ["o", "a", "k", ""]>>(true);
assertEquals("oak".split(""), ["o", "a", "k", ""]);
});
Check programs/template-literal-types.test.ts
running 9 tests from ./programs/template-literal-types.test.ts
...
recursion, and the method it agrees with ... FAILED (8ms)

ERRORS

recursion, and the method it agrees with => ./programs/template-literal-types.test.ts:172:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
"o",
"a",
"k",
+ "",
]

FAILURES

recursion, and the method it agrees with => ./programs/template-literal-types.test.ts:172:6

FAILED | 8 passed | 1 failed (10ms)

error: Test failed

"oak".split("") gives three elements. Split<"oak", ""> gives four, with a trailing empty string, and the diff pins the exact disagreement: the predicted "" never arrives. The type is not wrong about its own rules; it is just not String.prototype.split, and nothing announced the difference. Correct the prediction:

Deno.test("recursion, and the method it agrees with", () => {
type TrimStart<Str extends string> = Str extends ` ${infer Rest}`
? TrimStart<Rest>
: Str;

type TrimEnd<Str extends string> = Str extends `${infer Rest} `
? TrimEnd<Rest>
: Str;

type Trim<Str extends string> = TrimStart<TrimEnd<Str>>;

const trimmed: Trim<" text "> = "text";

assertType<IsExact<TrimStart<" text ">, "text ">>(true);
assertType<IsExact<TrimEnd<" text ">, " text">>(true);
assertStrictEquals(" text ".trim(), trimmed);

type Split<Str extends string, Sep extends string> = Str extends
`${infer Head}${Sep}${infer Tail}` ? [Head, ...Split<Tail, Sep>]
: [Str];

type Join<Parts extends Array<string>, Sep extends string> = Parts extends [
infer Head extends string,
...infer Tail extends Array<string>,
] ? Tail["length"] extends 0 ? Head : `${Head}${Sep}${Join<Tail, Sep>}`
: "";

assertType<IsExact<Split<"a-b-c", "-">, ["a", "b", "c"]>>(true);
assertEquals("a-b-c".split("-"), ["a", "b", "c"]);

assertType<IsExact<Join<["a", "b", "c"], "-">, "a-b-c">>(true);
assertStrictEquals(["a", "b", "c"].join("-"), "a-b-c");

// Two implementations of one algorithm, and they are not the same one.
assertType<IsExact<Split<"oak", "">, ["o", "a", "k", ""]>>(true);
assertEquals("oak".split(""), ["o", "a", "k"]);
});
recursion, and the method it agrees with ... ok (39µs)

This is the honest cost of computing with types, worth stating plainly because it is easy to lose sight of: you have implemented the same operation twice, once in a language with tests and a debugger and once in a language with neither, and the two copies can disagree. Every type-level algorithm in this entry has a run-time assertion beside it for that reason. Join also shows the other half of the pattern, recursing over a tuple rather than a string with Tail["length"] extends 0 as the base case, which is tuple types territory.

recursion without a separator walks code units

Deno.test("recursion without a separator walks code units", () => {
type CodeUnits<Str extends string> = Str extends `${infer Head}${infer Tail}`
? [Head, ...CodeUnits<Tail>]
: [];

assertType<
IsExact<CodeUnits<"rainbow">, ["r", "a", "i", "n", "b", "o", "w"]>
>(true);
assertType<IsExact<CodeUnits<"">, []>>(true);
assertType<IsExact<CodeUnits<"🌈">["length"], 2>>(true);

// Spreading a string iterates code points, so it disagrees.
assertStrictEquals([..."🌈"].length, 1);
assertStrictEquals("🌈".length, 2);
});
recursion without a separator walks code units ... ok (15µs)

With no separator between the two infers, the first one matches a single code unit. That gives you a character-by-character walk, which is the base of most clever string types, and it splits an emoji in half. The comparison is the useful part: spreading a string iterates code points, so [..."🌈"] has one element while the type-level walk produces two. If you are processing identifiers or ASCII this never comes up, and if you are processing anything a user typed, it does.

renaming property keys is what this is for

Deno.test("renaming property keys is what this is for", () => {
type JsonLd = {
"@context": string;
"@type": string;
datePublished: string;
};

type Underscored<Key> = Key extends `@${infer Rest}` ? `_${Rest}` : Key;

type Renamed<T> = { [Key in keyof T as Underscored<Key>]: T[Key] };

const document: Renamed<JsonLd> = {
_context: "https://schema.org",
_type: "Article",
datePublished: "2026-07-27",
};

assertEquals(Object.keys(document), ["_context", "_type", "datePublished"]);
assertStrictEquals(document._type, "Article");
});
renaming property keys is what this is for ... ok (18µs)

JsonLd describes structured data whose keys start with @, which means quoting them everywhere. One mapped type with as and a template literal renames them all, and the properties that do not match are left alone, because Underscored<Key> returns Key unchanged in its false branch. That last detail is what makes this usable: Underscored takes an unconstrained Key rather than Key extends string, so a symbol key passes straight through instead of failing to match a template. The mapped types page is the entry for the loop; this is the part that computes the new name. Of everything in this entry, key renaming is the thing most likely to earn its place in code you maintain: a wire format you do not control, mapped once into names your code can use.

one string of truth

Deno.test("one string of truth", () => {
assertType<
IsExact<PathParams<"/users/:userId/posts/:postId">, "userId" | "postId">
>(true);
assertType<IsExact<PathParams<"/users">, never>>(true);

const href = link("/users/:userId/posts/:postId", {
userId: "1234",
postId: "9",
});

// @ts-expect-error: Property 'postId' is missing in type '{ userId: string; }' but required in type 'Record<"userId" | "postId", string>'.
const missing = link("/users/:userId/posts/:postId", { userId: "1234" });

// @ts-expect-error: Object literal may only specify known properties, and 'extra' does not exist in type 'Record<"userId", string>'.
const invented = link("/users/:userId", { userId: "1", extra: "x" });

assertStrictEquals(href, "/users/1234/posts/9");
assertStrictEquals(missing, "/users/1234/posts/undefined");
assertStrictEquals(invented, "/users/1");
});
one string of truth ... ok (75µs)

Strip the two shields in a copy of the file and the promise in the prose becomes two captures:

Check programs/template-literal-types.test.ts
TS2345 [ERROR]: Argument of type '{ userId: string; }' is not assignable to parameter of type 'Record<"userId" | "postId", string>'.
Property 'postId' is missing in type '{ userId: string; }' but required in type 'Record<"userId" | "postId", string>'.
const missing = link("/users/:userId/posts/:postId", { userId: "1234" });
~~~~~~~~~~~~~~~~~~
at file:///programs/template-literal-types.test.ts:258:56

TS2353 [ERROR]: Object literal may only specify known properties, and 'extra' does not exist in type 'Record<"userId", string>'.
const invented = link("/users/:userId", { userId: "1", extra: "x" });
~~~~~
at file:///programs/template-literal-types.test.ts:260:58

Found 2 errors.

error: Type checking failed.

link takes the path and a Record<PathParams<Path>, string>, so the second argument's keys are computed from the first argument's value. Miss a parameter and it is a compile error; invent one and it is a compile error, the excess property check doing the second half. Six lines of type, one string of truth, and the run-time implementation is a replace call the matching and replacing page explains. The two run-time pins under the shields are worth a look too: with the shield letting the incomplete call through, the missing parameter comes out as the literal string "undefined" in the URL, which is the bug this type exists to prevent. That is the case where this feature is unambiguously worth it: a string that already encodes a structure, and code that needs to agree with it.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/branding.test.ts
Check programs/buffers-and-views.test.ts
Check programs/classes-as-values.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conditional-types.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/enum-patterns.test.ts
Check programs/enums.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/function-types.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/interfaces-and-type-aliases.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/keyof-and-indexed-access.test.ts
Check programs/loops.test.ts
Check programs/mapped-types.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/object-types.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/overloading.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/template-literal-types.test.ts
Check programs/testing-types.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/typed-arrays.test.ts
Check programs/typing-arrays.test.ts
Check programs/typing-classes.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 9 tests from ./programs/branding.test.ts
...
running 12 tests from ./programs/buffers-and-views.test.ts
...
running 10 tests from ./programs/classes-as-values.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 14 tests from ./programs/conditional-types.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 12 tests from ./programs/enum-patterns.test.ts
...
running 7 tests from ./programs/enums.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 12 tests from ./programs/function-types.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 9 tests from ./programs/interfaces-and-type-aliases.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/keyof-and-indexed-access.test.ts
...
running 14 tests from ./programs/loops.test.ts
...
running 12 tests from ./programs/mapped-types.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 15 tests from ./programs/object-types.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 8 tests from ./programs/overloading.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 12 tests from ./programs/template-literal-types.test.ts
every combination, and the refusal prints the union ... ok (131µs)
the case utilities are the JavaScript methods ... ok (36µs)
a union goes in, a union comes out ... ok (138µs)
the product is real, and there are two ceilings ... ok (15µs)
some primitive types can be interpolated ... ok (57µs)
a numeric template is a filter for keys ... ok (34µs)
infer inside a template takes a string apart ... ok (44µs)
one character in, two out ... ok (25µs)
recursion, and the method it agrees with ... ok (34µs)
recursion without a separator walks code units ... ok (14µs)
renaming property keys is what this is for ... ok (30µs)
one string of truth ... ok (70µs)
running 14 tests from ./programs/testing-types.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 14 tests from ./programs/typed-arrays.test.ts
...
running 10 tests from ./programs/typing-arrays.test.ts
...
running 12 tests from ./programs/typing-classes.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 796 passed | 0 failed (909ms)

Twelve tests, and the practice is short. Use it for string shapes that are real: a method and a path, a version, a prefixed key, a CSS custom property, types with genuinely few legal values where writing them out is worse than computing them. Use it to select, not just to construct, because Extract<keyof T, `${number}`> and Exclude<T, `_${string}`> are the two lines from this entry you are most likely to reach for again. Do not write a parser: it is possible, the entries that describe doing it are entertaining, and the result has bad error messages, a thousand-step recursion limit, and no tests, so parse at run time and validate, because the type level is for shapes rather than grammar. Put a run-time assertion beside every type-level string algorithm you write, since Split and split are not the same function and the only way you will find out is by checking both. And print what you built: a union of six route strings is fine to imagine and a union of forty-eight is not, so annotate something with the type, assign the wrong value to it, and read the error.