Unreleased changes
These pages document main, which has changes 2.1.1 does not. npm install @evanion/urn gives you 2.1.1, documented here. Values on these pages are produced by running the current source on every build.
API reference
@evanion/urn exports one class, two functions, two errors and four types, one
heading each below. It needs Node 20 or newer and ships ESM only, which
require() loads on Node 20.19, 22.12 or newer.
A URN, as defined by RFC 8141, with one deliberate deviation: RFC 8141
requires the literal scheme urn, and this class allows any scheme through
the overridable URN.urn static.
Full documentation
The class conforms to RFC 8141 only under the default : separator. A
subclass that overrides URN.separator checks the scheme and the NID
against a character class that excludes the separator, with no length
bounds, and is not RFC 8141 conformant. The NSS keeps the RFC grammar under
every separator.
The optional r-, q- and f-components of RFC 8141 2.3 are split off the tail
before the separator split, under every separator except one that itself
contains ? or #. Such a separator cannot be told apart from a component
introducer, so on that subclass parse throws an InvalidError with
property: 'NSS' for any ? or # after the NID, and stringify throws
one with property: 'COMPONENT' for any component.
Every static reads this, so they cannot be destructured the way
JSON.stringify can: const { stringify } = URN then stringify('a')
throws a TypeError, because the default parameter nid = this.nid runs
with this undefined. Call them on the class.
URN is never instantiated. A subclass overrides the statics in the first
three rows. parse, stringify and isValidFormat read all three off this.
extractId, equals and sameNamespace read only separator, and
belongsToNamespace reads separator and defaults its scheme to the class’s
urn. GameURN in the examples on this page is URN with
static override readonly nid = 'game'.
| Static | Default | What it is |
|---|---|---|
urn | 'urn' | the scheme this class writes and reads as its own |
nid | 'nid' | the namespace identifier this class writes and reads as its own |
separator | ':' | what the three parts are joined by and split on |
schemeGrammar | RFC 3986 scheme | a letter, then letters, digits, +, - and . |
nidGrammar | RFC 8141 NID | letters, digits and interior -, 2 to 32 characters, checked by stringify |
nssGrammar | RFC 8141 NSS | letters, digits, -._~!$&'()*+,;=:@ and percent-encoded characters, plus / after the first character |
rComponentGrammar | pchar *(pchar / "/" / "?") | the NSS set plus ? after the first character |
qComponentGrammar | pchar *(pchar / "/" / "?") | the same production as the r-component |
fComponentGrammar | RFC 3986 fragment | the NSS set plus ?, no first-character rule, and the only one to accept '' |
'nid' is a placeholder, not a registered namespace, so every real namespace
is foreign to the base URN class. Under a separator other than :,
schemeGrammar and nidGrammar become a character class that excludes the
separator, with no length bounds. parse accepts a one-character NID, which
RFC 2141, the URN syntax RFC 8141 replaced, permitted and nidGrammar refuses:
..('baize'); // -> true
..('game'); // -> true
..('g'); // -> false
.('urn:g:azul'); // -> true
..('brass-birmingham'); // -> true
..('brass birmingham'); // -> falsePassed as a bare callback, a static loses this. stringify, parse and
extractId throw a TypeError. isValidFormat, equals and sameNamespace
catch it and return false. belongsToNamespace throws only when its scheme
argument is left out, and returns false under filter or map, which pass
a third argument:
const = ['urn:game:azul', 'urn:game:hive'];
.(() => .()); // -> ['azul', 'hive']
let = '';
try {
.(.);
} catch () {
= ( as Error).;
}
; // -> 'TypeError'
.(.); // -> []
.(() => .()); // -> ['urn:game:azul', 'urn:game:hive']export declare class {
static readonly : string;
static readonly : string;
static readonly : string;
static get (): RegExp;
static get (): RegExp;
static get (): RegExp;
static get (): RegExp;
static get (): RegExp;
static get (): RegExp;
protected static get (): RegExp;
private static get ();
private static get ();
private static get ();
static (: string): ParsedURN;
static (: URNParts): string;
static (: string, ?: string, ?: string): string;
static (: string): boolean;
static (: string): string;
static (: string, : string): boolean;
static (: string, : string, ?: string): boolean;
static (: string, : string): boolean;
private static ;
private static ;
private static ;
private static ;
private static ;
private static ;
private static ;
private static ;
private static ;
private static ;
}What the tests state
102 behaviours
stringify
NID grammar
NSS grammar
scheme grammar
round-tripping
class inheritance
custom separator
parse
utility methods
percent-encoding helpers
malformed input
empty components
subclass awareness
r-, q- and f-components
the object form of stringify
error hierarchy
stringifies a basic URN
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/urn';
class extends {
static override readonly = 'game';
}
const = .('brass-birmingham');
; // -> 'urn:game:brass-birmingham'
let = '';
try {
.('brass birmingham');
} catch () {
if ( instanceof ) = .;
}
; // -> "NSS contains invalid character ' ' in 'brass birmingham'"URN.stringify
stringify(parts) and stringify(nss, nid?, urn?) write a URN string. nid
and urn default to the calling class’s own statics. Both forms throw
InvalidError when a part is empty or breaks its grammar:
.('azul'); // -> 'urn:game:azul'
.('azul', 'game'); // -> 'urn:game:azul'
.('azul', 'game', 'baize'); // -> 'baize:game:azul'
.({ : 'azul', : 'game' }); // -> 'urn:game:azul'
.(.('urn:game:azul')); // -> 'urn:game:azul'The object form’s keys match what parse returns, and only the object form
writes the r-, q- and f-components. The positional arguments run in the reverse
order of parse’s return, so stringify(...Object.values(parse(x))) puts the
scheme where the NSS belongs. stringify writes the NSS verbatim, so it neither
deduplicates nor is idempotent:
.('game:azul'); // -> 'urn:game:game:azul'
.('urn:game:game:azul').; // -> 'game:azul'
.(.('azul')); // -> 'urn:game:urn:game:azul'URN.parse
parse(urnString) returns a ParsedURN, with every part in the case it was
written in and every percent-encoded character intact. A scheme or NID that
differs from the class’s own, compared without regard to case, stays in the
nss:
.('urn:game:azul'); // -> { urn: 'urn', nid: 'game', nss: 'azul' }
.('URN:GAME:azul'); // -> { urn: 'URN', nid: 'GAME', nss: 'azul' }
.('urn:order:order-2026-0042'); // -> { urn: 'urn', nid: 'order', nss: 'order:order-2026-0042' }
.('baize:game:azul'); // -> { urn: 'baize', nid: 'game', nss: 'baize:game:azul' }parse throws ValidationError for a string that is not a URN, and
InvalidError for a part that breaks its grammar.
URN.isValidFormat
isValidFormat(urnString) returns true when parse would succeed. It calls
parse, so the two cannot disagree, and it never throws:
function (: string) {
return .() ? .() : null;
}
('urn:game:hive'); // -> { urn: 'urn', nid: 'game', nss: 'hive' }
('urn:game:spirit island'); // -> null
('hive'); // -> null
('urn:order:order-2026-0042'); // -> { urn: 'urn', nid: 'order', nss: 'order:order-2026-0042' }URN.extractId
extractId(urnString) returns everything after the scheme and the NID, with
any r-, q- or f-component dropped. On a foreign namespace it differs from
parse(urnString).nss, which keeps the NID. The base URN class’s own NID is
'nid', so game is foreign to it:
.('urn:game:azul').; // -> 'game:azul'
.('urn:game:azul').; // -> 'game'
.('urn:game:azul'); // -> 'azul'extractId throws ValidationError on malformed input.
URN.sameNamespace
sameNamespace(a, b) returns true when both URNs have the same scheme and
the same NID, compared case-folded. Malformed input returns false:
.('urn:game:azul', 'URN:Game:hive'); // -> true
.('urn:game:azul', 'urn:order:order-2026-0042'); // -> false
.('azul', 'azul'); // -> falseURN.belongsToNamespace
belongsToNamespace(urnString, nid, urn?) returns true when the URN is in the
named namespace. urn defaults to the calling class’s own scheme. Malformed
input returns false:
.('urn:game:azul', 'game'); // -> true
.('baize:game:azul', 'game'); // -> false
.('baize:game:azul', 'game', 'baize'); // -> trueURN.equals
equals(a, b) is RFC 8141 §3.1 equivalence. The scheme and the NID fold case.
The NSS compares character for character, except that the hex digits of a
percent-triplet fold to uppercase. A percent-encoded octet is never decoded, and
the r-, q- and f-components are ignored. Malformed input returns false:
.('URN:GAME:azul', 'urn:game:azul'); // -> true
.('urn:game:Brass%3a%20Birmingham', 'urn:game:Brass%3A%20Birmingham'); // -> true
.('urn:game:Brass%3A%20Birmingham', 'urn:game:Brass:%20Birmingham'); // -> false
.('urn:game:Azul', 'urn:game:azul'); // -> falsePercent-encodes an arbitrary string into a valid NSS.
Full documentation
Everything outside RFC 3986’s unreserved set is encoded as UTF-8
percent-triplets with uppercase hex, so the result is safe in any position
of an NSS. Neither stringify nor parse calls this: encoding is an
explicit, separate step, so a value can never be double-encoded by accident.
encodeNss(raw) leaves A-Z a-z 0-9 - . _ ~ alone and percent-encodes every
other character, : included.
function (: string): string;
What the tests state
2 behaviours
encodeNss
encodes everything outside the unreserved set
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , , } from '@evanion/urn';
.('urn:game:Brass: Birmingham'); // -> false
const = ('Brass: Birmingham');
; // -> 'Brass%3A%20Birmingham'
const = .(, 'game');
; // -> 'urn:game:Brass%3A%20Birmingham'
(.()); // -> 'Brass: Birmingham'Decodes the percent-triplets in an NSS back to the raw string.
throws {ValidationError} when the input contains a malformed or truncated percent sequence.
decodeNss(encoded) reverses encodeNss. It throws ValidationError on a %
that is not followed by two hex digits, and on triplets that do not decode as
UTF-8.
function (: string): string;
What the tests state
2 behaviours
decodeNss
decodes triplets the library itself never produces
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/urn';
('Brass%3A%20Birmingham'); // -> 'Brass: Birmingham'
let = '';
try {
('Brass%3');
} catch () {
if ( instanceof ) = .;
}
; // -> "Malformed percent-encoding in 'Brass%3': '%' must be followed by two hex digits."Base class for every error this library throws. Catch it to handle any validation failure without naming the subclasses.
ValidationError is the base class of every error @evanion/urn throws.
parse, extractId and decodeNss throw it directly for input that is not a
URN or not a valid encoding, so catching it covers InvalidError too.
export declare class extends {
constructor(: string);
}import { , , } from '@evanion/urn';
function (: string): string {
try {
.();
return 'none';
} catch () {
if ( instanceof ) return `bad ${.}`;
if ( instanceof ) return 'not a URN';
throw ;
}
}
('urn:game:azul'); // -> 'none'
('urn:game:spirit island'); // -> 'bad NSS'
('azul'); // -> 'not a URN'Thrown when one part of a URN is empty, contains a character outside its role’s permitted set, or breaks a structural rule of its role’s grammar (a length bound, a leading hyphen, a truncated percent-triplet).
InvalidError extends ValidationError, and stringify and parse throw it
for one part that is empty or breaks its grammar.
| Property | What it holds |
|---|---|
property | the part: 'URN' for the scheme, 'NID', 'NSS', 'R-COMPONENT', 'Q-COMPONENT', 'F-COMPONENT' or 'COMPONENT' |
value | the value of that part |
invalidChar | the first character outside the part’s set, when one is at fault |
reason | the structural rule broken when every character is allowed, such as a length bound |
'COMPONENT' is the property when a subclass whose separator contains ? or
# is asked to write a component.
export declare class extends {
constructor(: string, : string, ?: string, ?: string);
private static ;
readonly : string;
readonly : string;
readonly ?: string;
readonly ?: string;
}import { , } from '@evanion/urn';
function (: string, : string): | undefined {
try {
.(, );
return ;
} catch () {
if ( instanceof ) return ;
throw ;
}
}
const = ('spirit island', 'game');
?.; // -> 'NSS'
?.; // -> ' '
?.; // -> "NSS contains invalid character ' ' in 'spirit island'"
const = ('spirit-island', 'g');
?.; // -> 'NID'
?.; // -> 'must be at least 2 characters long'The object returned by URN.parse.
Full documentation
The parts are plain strings, and deliberately not type parameters. parse
takes a runtime string, so it cannot know their literal types; free type
parameters here would let URN.parse<'a', 'b', 'c'>(someString) assert a
shape nothing verifies.
The three component keys are absent rather than undefined when the URN
carries no components, so such a URN parses to exactly { urn, nid, nss }.
ParsedURN is what parse returns: urn, nid and nss, plus whichever
components the URN carries.
interface ParsedURN extends URNComponents {
: string;
: string;
: string;
}
.('urn:game:brass-birmingham?=edition=2018#setup'); // -> { urn: 'urn', nid: 'game', nss: 'brass-birmingham', fComponent: 'setup', qComponent: 'edition=2018' }The object form of URN.stringify’s arguments.
Full documentation
The keys match ParsedURN, so a parsed URN can be handed straight
back: on one class, stringify(parse(x)) returns x for a URN in that
class’s own namespace. The scheme and the NID are optional and fall back to
the class’s own, which is what makes { nss } alone valid.
URNParts is what the object form of stringify takes. Only nss is
required, and urn and nid fall back to the class’s own. A ParsedURN is
assignable to it.
interface URNParts extends URNComponents {
?: string;
?: string;
: string;
}
import type { ParsedURN, URNParts } from '@evanion/urn';
class extends {
static override readonly = 'game';
}
const : ParsedURN = .('urn:game:azul#setup');
const : URNParts = { ..., : 'scoring' };
.(); // -> 'urn:game:azul#scoring'
const : URNParts = { : 'hive' };
.(); // -> 'urn:game:hive'The three optional components RFC 8141 2.3 allows after the NSS.
Full documentation
Each is carried in its wire form, without its introducing delimiter and
without any percent-decoding. RFC 8141 3.1 excludes all three from
URN-equivalence, so URN.equals ignores them.
URNComponents holds rComponent, qComponent and fComponent, each
optional. A key is absent when the URN carries no such component, and
fComponent may be ''. r-, q- and f-components covers the
syntax.
interface URNComponents {
?: string;
?: string;
?: string;
}
function ({ , }: URNComponents): string {
return [, ].().(' / ');
}
(.('urn:game:azul?=edition=2017#scoring')); // -> 'edition=2017 / scoring'
'qComponent' in .('urn:game:azul'); // -> falseA URN string with known parts, for annotating literals in consumer code.
Full documentation
Assumes the default : separator. A subclass with a custom separator cannot
be described by this type.
IFullURN<URN, NID, NSS> is the template literal type
`${URN}:${NID}:${NSS}`. stringify returns plain string, because a
subclass may override the :.
export type < extends string, extends string, extends string> = `${}:${}:${}`;import type { } from '@evanion/urn';
type = <'urn', 'game', string>;
const : = 'urn:game:azul';
.(); // -> 'azul'
// @ts-expect-error: an order URN is not a GameId
const : = 'urn:order:order-2026-0042';
.(); // -> true