Ahead of the release
npm install @evanion/urn gives you 2.0.0. These pages document main, which has changes that release does not.
API reference
Every export of @evanion/urn. Node 20 or newer; the package is ESM only.
URN
A class whose entire surface is static. Nothing is ever instantiated — URN is
a namespace for the operations and a carrier for the three configurable values a
subclass overrides.
class URN {
static readonly urn: string; // 'urn'
static readonly nid: string; // 'nid'
static readonly separator: string; // ':'
static get schemeGrammar(): RegExp;
static get nidGrammar(): RegExp;
static get nssGrammar(): RegExp;
static get rComponentGrammar(): RegExp;
static get qComponentGrammar(): RegExp;
static get fComponentGrammar(): RegExp;
static stringify(parts: URNParts): string;
static stringify(nss: string, nid?: string, urn?: string): string;
static parse(urnString: string): ParsedURN;
static isValidFormat(urnString: string): boolean;
static extractId(urnString: string): string;
static sameNamespace(a: string, b: string): boolean;
static belongsToNamespace(
urnString: string,
nid: string,
urn?: string,
): boolean;
static equals(a: string, b: string): boolean;
}Every static reads this, so none of them can be destructured off the class.
const { stringify } = URN; stringify('a') throws a TypeError, because the
default parameter nid = this.nid runs with this undefined.
Class properties
| Static | Default | Meaning |
|---|---|---|
urn | 'urn' | the scheme this class writes and recognises as its own |
nid | 'nid' | the namespace this class writes and recognises as its own |
separator | ':' | what the three parts are joined by and split on |
'nid' is a placeholder, not a registered namespace; IANA’s URN namespace
registry holds the real ones. Overriding nid is what makes stringify(nss)
need no namespace argument, and what lets parse tell its own namespace from a
foreign one.
class MyURN extends URN {
static override readonly urn = 'my-scheme';
static override readonly nid = 'my-namespace';
}
MyURN.stringify('example'); // 'my-scheme:my-namespace:example'stringify(parts) / stringify(nss, nid?, urn?)
Writes a URN string. nid and urn default to the calling class’s own statics.
URN.stringify('example', 'my-namespace'); // 'urn:my-namespace:example'
URN.stringify('example', 'my-namespace', 'custom'); // 'custom:my-namespace:example'
URN.stringify({ nss: 'example', nid: 'my-namespace' }); // 'urn:my-namespace:example'
URN.stringify({ nss: 'weather', nid: 'example', qComponent: 'lat=39' });
// 'urn:example:weather?=lat=39'Prefer the object form. Its keys match parse’s return shape, it is the only
form that can emit the r-, q- and f-components, and the positional arguments are
in the reverse order of what parse returns — so
stringify(...Object.values(parse(x))) is silently wrong.
It does not deduplicate. Whatever you pass as the NSS is emitted verbatim after the scheme and the NID:
URN.stringify('my-namespace:example', 'my-namespace');
// 'urn:my-namespace:my-namespace:example'That is what keeps a composite key imported from another system recoverable: if
the repeated segment were dropped, my-namespace:example could never be read
back. Name the namespace you meant instead —
URN.stringify('example', 'my-namespace').
It is also not idempotent and is not a normaliser:
.(.('foo')); // -> 'urn:nid:urn:nid:foo'Throws InvalidError when a part is empty or breaks its role’s grammar.
parse(urnString)
Reads a URN into ParsedURN. Nothing is normalised, nothing is decoded: the
parts come back in their original case with percent-triplets intact, because RFC
8141 §3.1 requires that an encoded octet stay opaque for equivalence purposes.
URN.parse('urn:nid:example');
// { urn: 'urn', nid: 'nid', nss: 'example' }
URN.parse('urn:other-namespace:example');
// { urn: 'urn', nid: 'other-namespace', nss: 'other-namespace:example' }
URN.parse('custom:my-namespace:example');
// { urn: 'custom', nid: 'my-namespace', nss: 'custom:my-namespace:example' }A scheme or NID that differs from the parsing class’s own is retained inside the
nss rather than discarded. The base class’s own NID is 'nid', which is why
the second and third calls above look the way they do — see
the namespace rule.
The read path is lenient in exactly one respect: it accepts a one-character NID, which RFC 2141 permitted.
Throws ValidationError when the string is not a well-formed URN.
isValidFormat(urnString)
true when the string parses. It delegates to parse, so the two cannot drift
apart, and it never throws — which makes it the cheap way to screen input before
committing to parse.
extractId(urnString)
Everything after the scheme and the NID, with any r-, q- or f-component dropped.
This is deliberately structural and differs from parse(urnString).nss on a
foreign namespace: parse keeps a non-matching NID attached so the namespace is
not lost, extractId always drops it.
URN.parse('urn:user:123').nss; // 'user:123'
URN.extractId('urn:user:123'); // '123'Reach for parse when the namespace matters, extractId when you only want the
trailing identifier. Throws ValidationError on malformed input.
sameNamespace(a, b)
true when both URNs share a scheme and a NID, compared case-insensitively.
Returns false for malformed input rather than throwing.
URN.sameNamespace('urn:user:1', 'URN:User:2'); // true
URN.sameNamespace('urn:user:1', 'urn:order:2'); // falsebelongsToNamespace(urnString, nid, urn?)
true when the URN is in the given namespace. urn defaults to this class’s own
scheme, so a subclass does not repeat it:
class TRN extends URN {
static override readonly urn = 'trn';
static override readonly nid = 'bar';
}
TRN.belongsToNamespace('trn:bar:foo', 'bar'); // true
URN.belongsToNamespace('ftp:user:1', 'user'); // falseequals(a, b)
RFC 8141 §3.1 equivalence. The scheme and the NID fold case; the NSS is compared
character for character, except that the hex digits of a percent-triplet
canonicalise to uppercase. An encoded octet is never decoded, so %2C and ,
are not equivalent. The r-, q- and f-components are excluded.
.('URN:Example:a123%2cz456', 'urn:example:a123%2Cz456'); // -> true
.('urn:example:a123%2Cz456', 'urn:example:a123,z456'); // -> false
.('urn:example:A123', 'urn:example:a123'); // -> falseReturns false for malformed input.
Functions
encodeNss(raw)
Percent-encodes everything outside RFC 3986’s unreserved set (A-Z a-z 0-9
and - . _ ~) as UTF-8 triplets with uppercase hex, so the result is valid in
any NSS position.
decodeNss(encoded)
The inverse. Throws ValidationError on a malformed or truncated percent
sequence.
import { , } from '@evanion/urn';
('café'); // -> 'caf%C3%A9'
('caf%C3%A9'); // -> 'café'Neither stringify nor parse calls either of these. Encoding is a separate,
explicit step, so a value can never be double-encoded by accident.
Grammars
Six getters, all readable off the class, all reactive to a subclass’s
separator:
.; // -> /^[A-Za-z][A-Za-z0-9+.-]*$/
.; // -> /^[A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]$/
.; // the RFC 8141 `pchar *(pchar / "/")` set
.; // `pchar *(pchar / "/" / "?")`
.; // the same production
.; // RFC 3986 `fragment`, which may be emptyThere is no single flat validation regex. One character class cannot describe three roles across two separator regimes; these getters can.
| Role | Allowed |
|---|---|
| scheme | a letter, then letters, digits, +, -, . |
| NID | letters and digits, plus - in the interior; 2–32 writing, 1–32 reading |
| NSS | pchar — letters, digits, -._~!$&'()*+,;=:@ and percent-triplets — plus / after the first |
| r-, q-component | the NSS set plus ? after the first character |
| f-component | the NSS set plus ?, no first-character rule, and may be empty |
Everything outside a role’s set must be percent-encoded. nidGrammar is the
write grammar; the read path uses an unexported variant with a floor of one
character.
Custom separators are not RFC 8141
A subclass that overrides separator gets a generic character class with the
separator excluded, for the scheme and the NID, and no RFC length bounds. The
NSS keeps the RFC grammar under every separator, because split-then-rejoin on
the same delimiter is lossless and the NSS therefore cannot structurally collide
with it.
class DatabaseURN extends URN {
static override readonly urn = 'db';
static override readonly separator = '.';
}
DatabaseURN.stringify('users', 'table'); // 'db.table.users'A separator containing ? or # also switches component parsing off — see
r-, q- and f-components. Such a subclass is not claimed to be
RFC 8141 conformant.
Types
ParsedURN
interface ParsedURN extends URNComponents {
urn: string;
nid: string;
nss: string;
}The parts are plain strings and deliberately not type parameters: parse takes
a runtime string, so it cannot know their literal types, and free type
parameters would let URN.parse<'a', 'b', 'c'>(someString) assert a shape
nothing verifies.
URNParts
interface URNParts extends URNComponents {
urn?: string; // defaults to the class's own scheme
nid?: string; // defaults to the class's own NID
nss: string;
}A ParsedURN is assignable to it.
URNComponents
interface URNComponents {
rComponent?: string;
qComponent?: string;
fComponent?: string;
}Absent, not undefined, when the URN carries none.
IFullURN
type IFullURN<
URN extends string,
NID extends string,
NSS extends string,
> = `${URN}:${NID}:${NSS}`;For annotating literals in consumer code:
type UserUrn = IFullURN<'urn', 'user', string>; // `urn:user:${string}`
const id: UserUrn = 'urn:user:123';It assumes the default : separator, so a subclass with a custom one cannot be
described by it. stringify returns plain string rather than a template
literal type for the same reason.
Errors
ValidationError
Base class for everything the library throws. Catch it to handle any validation failure without naming the subclasses.
InvalidError extends ValidationError
A component was empty, contained a disallowed character, or broke a structural rule of its grammar.
| Property | Meaning |
|---|---|
property | which role failed: 'URN', 'NID', 'NSS', or a component name |
value | the offending value |
invalidChar | the first disallowed character, when one could be identified |
reason | why it failed when no single character is at fault |
URN.stringify('a b', 'example');
// InvalidError: NSS contains invalid character ' ' in 'a b'
// property: 'NSS', value: 'a b', invalidChar: ' '
URN.stringify('1', 'x');
// InvalidError: NID is invalid in 'x': must be at least 2 characters long
// property: 'NID', value: 'x', reason: 'must be at least 2 characters long'parse and extractId throw ValidationError; stringify throws
InvalidError. Catching ValidationError covers both.