Overview
Introduce nominal (opaque) types for domain-specific values used internally throughout tough-cookie. These types enforce that values pass through validation/construction before use, preventing bugs like passing raw domains where canonicalized ones are expected, or mixing up seconds and milliseconds.
Motivation
Many internal functions accept plain string, number, or boolean for values that have specific semantic constraints defined by RFC 6265 and RFC 6265bis. For example:
domainMatch() expects canonicalized domains but accepts any string
pathMatch() expects paths starting with / but accepts any string
Cookie.sameSite accepts any string but only 'strict' | 'lax' | 'none' are valid
Cookie.maxAge is in seconds but nothing in the type system distinguishes it from milliseconds
Nominal type pattern
Each nominal type uses a unique symbol brand + namespace pattern:
// The unique symbol is NOT exported — external code can't reference it
declare const tag: unique symbol;
export type CanonicalDomain = string & { readonly [tag]: true };
export namespace CanonicalDomain {
export function parse(input: string): CanonicalDomain | undefined {
// validation + canonicalization logic
return result as CanonicalDomain;
}
}
This gives us:
- Zero runtime cost — values remain plain strings/numbers at runtime
Type.parse() API — the only way to construct a nominal value from external modules
- Compile-time safety — prevents accidental misuse without
as any as Type double-assertion
- Declaration merging — the type and namespace share the same name for a clean API
Public API compatibility
All changes target internal code. The strategy is:
- Exported functions keep their current
string-based signatures as thin wrappers
- Internal functions use nominal types (the "pure algorithm" layer)
- Cookie/Store class properties remain plain
string | null publicly
- String literal unions (
SameSiteLevel, PrefixSecurityMode) may narrow public types where the current string type is demonstrably incorrect
Sub-issues
High priority:
Medium priority:
Lower priority:
References
Overview
Introduce nominal (opaque) types for domain-specific values used internally throughout tough-cookie. These types enforce that values pass through validation/construction before use, preventing bugs like passing raw domains where canonicalized ones are expected, or mixing up seconds and milliseconds.
Motivation
Many internal functions accept plain
string,number, orbooleanfor values that have specific semantic constraints defined by RFC 6265 and RFC 6265bis. For example:domainMatch()expects canonicalized domains but accepts anystringpathMatch()expects paths starting with/but accepts anystringCookie.sameSiteaccepts anystringbut only'strict' | 'lax' | 'none'are validCookie.maxAgeis in seconds but nothing in the type system distinguishes it from millisecondsNominal type pattern
Each nominal type uses a unique symbol brand + namespace pattern:
This gives us:
Type.parse()API — the only way to construct a nominal value from external modulesas any as Typedouble-assertionPublic API compatibility
All changes target internal code. The strategy is:
string-based signatures as thin wrappersstring | nullpubliclySameSiteLevel,PrefixSecurityMode) may narrow public types where the currentstringtype is demonstrably incorrectSub-issues
High priority:
CanonicalDomain— branded type for canonicalized domain namesCookiePath— branded type for validated cookie pathsSameSiteLevel— string literal union ('strict' | 'lax' | 'none')Medium priority:
MaxAgeSeconds— branded number type for max-age in secondsCookieName— branded type for validated cookie namesCookieValue— branded type for validated cookie valuesPrefixSecurityMode— string literal union ('silent' | 'strict' | 'unsafe-disabled')Lower priority:
CookieExpiryDate— branded Date type for RFC-parsed expiry datesRequestHost— branded type for raw hostnames (pre-canonicalization)CookieDateString— branded type for RFC 6265 date stringsReferences