hanzio 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +114 -3
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-6ragggbs.js +473 -0
  23. package/dist/index-mq2qgc20.js +116 -0
  24. package/dist/index-najbyzpr.js +99 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +14 -4
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/zod/index.d.ts +10 -0
  65. package/dist/zod/introspect.d.ts +2 -1
  66. package/package.json +96 -2
  67. package/src/api-wrapper/README.md +52 -0
  68. package/src/api-wrapper/connection-auth.ts +142 -0
  69. package/src/cli/README.md +220 -0
  70. package/src/cli/fields.ts +213 -0
  71. package/src/cli/index.ts +532 -0
  72. package/src/format/README.md +82 -2
  73. package/src/format/index.ts +148 -0
  74. package/src/format/phone.ts +43 -0
  75. package/src/http/README.md +25 -0
  76. package/src/http/index.ts +94 -0
  77. package/src/i18n/README.md +206 -3
  78. package/src/i18n/catalog.ts +139 -0
  79. package/src/i18n/check.ts +376 -0
  80. package/src/i18n/cli.ts +90 -0
  81. package/src/i18n/content-rules.ts +179 -0
  82. package/src/i18n/i18next.ts +89 -0
  83. package/src/i18n/index.ts +34 -0
  84. package/src/i18n/locales.ts +135 -0
  85. package/src/i18n/vite.ts +40 -0
  86. package/src/i18n/vue.ts +82 -0
  87. package/src/index.ts +1 -1
  88. package/src/iso/README.md +44 -0
  89. package/src/iso/countries.ts +272 -0
  90. package/src/iso/currencies.ts +172 -0
  91. package/src/iso/index.ts +8 -0
  92. package/src/iso/languages.ts +208 -0
  93. package/src/iso/phone-language.ts +46 -0
  94. package/src/promise/index.ts +100 -0
  95. package/src/redis/README.md +112 -0
  96. package/src/redis/client.ts +239 -0
  97. package/src/redis/concurrency-limit.ts +591 -0
  98. package/src/redis/index.ts +15 -0
  99. package/src/redis/lua-script.ts +43 -0
  100. package/src/secrets/README.md +5 -1
  101. package/src/secrets/infisical-identity-file.ts +69 -0
  102. package/src/secrets/infisical.ts +18 -3
  103. package/src/string/index.ts +59 -0
  104. package/src/testing/README.md +119 -0
  105. package/src/testing/connection-auth-contract.ts +303 -0
  106. package/src/testing/helpers.ts +41 -0
  107. package/src/testing/index.ts +12 -0
  108. package/src/testing/module-mocker.ts +50 -0
  109. package/src/testing/prisma.ts +78 -0
  110. package/src/testing/window.ts +119 -0
  111. package/src/zod/index.ts +20 -0
  112. package/src/zod/introspect.ts +1 -1
@@ -0,0 +1,89 @@
1
+ // i18next bindings for apps (React Native, React) that keep `{name}`
2
+ // placeholders. Types only from `i18next`: you pass your own instance, already
3
+ // `.use(initReactI18next)` where you need React.
4
+
5
+ import type { i18n as I18next, InitOptions, ResourceLanguage } from 'i18next'
6
+ import { type AsyncStorageLike, createAsyncStorageItem } from '../storage'
7
+ import type { LocaleRegistry } from '.'
8
+
9
+ /**
10
+ * Starts `i18n` in the persisted locale, then the first supported device
11
+ * locale, then `locales.defaultLocale` (also the fallback). Interpolation uses
12
+ * `{name}` (not `{{name}}`) and does not escape, matching the catalog check
13
+ * and `hanzio/i18n`.
14
+ *
15
+ * ```ts
16
+ * export const appI18n = initI18next(i18n.use(initReactI18next), {
17
+ * locales: uiLocales,
18
+ * resources: { en: { translation: en }, da: { translation: da } },
19
+ * deviceLocales: () => getLocales().flatMap((l) => [l.languageTag, l.languageCode]),
20
+ * storageKey: 'app.ui-locale',
21
+ * storage: AsyncStorage
22
+ * })
23
+ * ```
24
+ */
25
+ export function initI18next<L extends string>(
26
+ i18n: I18next,
27
+ options: Omit<InitOptions, 'lng' | 'resources'> & {
28
+ locales: LocaleRegistry<L>
29
+ resources: Record<L, ResourceLanguage>
30
+ /** Device languages in preference order, e.g. from `expo-localization`. */
31
+ deviceLocales?: () => readonly (string | null | undefined)[]
32
+ /** Remembers the chosen locale (a raw string). Needs `storage`. */
33
+ storageKey?: string
34
+ storage?: AsyncStorageLike
35
+ }
36
+ ) {
37
+ const { locales, deviceLocales, storageKey, storage, ...initOptions } =
38
+ options
39
+ const persisted =
40
+ storageKey && storage
41
+ ? createAsyncStorageItem<string | null>({
42
+ key: storageKey,
43
+ fallback: null,
44
+ storage,
45
+ serialize: (value) => value ?? '',
46
+ deserialize: (raw) => raw
47
+ })
48
+ : undefined
49
+
50
+ const ready = (async () => {
51
+ const locale = locales.resolve(
52
+ await persisted?.get(),
53
+ ...(deviceLocales?.() ?? [])
54
+ )
55
+ await i18n.init({
56
+ fallbackLng: locales.defaultLocale,
57
+ returnNull: false,
58
+ ...initOptions,
59
+ interpolation: {
60
+ escapeValue: false,
61
+ prefix: '{',
62
+ suffix: '}',
63
+ ...initOptions.interpolation
64
+ },
65
+ lng: locale
66
+ })
67
+ })()
68
+
69
+ async function setLocale(locale: L) {
70
+ if (i18n.language !== locale) await i18n.changeLanguage(locale)
71
+ await persisted?.set(locale)
72
+ }
73
+
74
+ return {
75
+ /** Resolves once `i18n.init` finishes. Await it before the first render. */
76
+ ready,
77
+ setLocale,
78
+ /** The active locale, or the default before `ready`. */
79
+ locale: () => locales.match(i18n.language) ?? locales.defaultLocale,
80
+ /**
81
+ * Follows a server-side preference (the signed-in user's locale). An
82
+ * unsupported or missing tag changes nothing.
83
+ */
84
+ async syncLocale(tag: string | null | undefined) {
85
+ const locale = locales.match(tag)
86
+ if (locale) await setLocale(locale)
87
+ }
88
+ }
89
+ }
package/src/i18n/index.ts CHANGED
@@ -123,3 +123,37 @@ export function createTranslator<const T extends Messages>(messages: T) {
123
123
  ...(args as [Record<string, PlaceholderValue>?])
124
124
  )
125
125
  }
126
+
127
+ /** Generated key → placeholder-name map: `{ 'greeting': 'name', 'common.save': never }`. */
128
+ export type TranslationParamsMap = Record<string, string>
129
+
130
+ /**
131
+ * Strict `t` over a generated key → placeholder-name map (see `hanzio-i18n
132
+ * generate`). Keys without placeholders take no params; keys with placeholders
133
+ * require every named value.
134
+ */
135
+ export type TypedTranslate<Params extends TranslationParamsMap> = <
136
+ Key extends keyof Params & string
137
+ >(
138
+ ...args: [Params[Key]] extends [never]
139
+ ? [key: Key]
140
+ : [key: Key, params: Record<Params[Key], PlaceholderValue>]
141
+ ) => string
142
+
143
+ /** Explicit escape hatch for data-driven keys validated outside TypeScript. */
144
+ export type DynamicTranslate = (
145
+ key: string,
146
+ params?: Record<string, PlaceholderValue>
147
+ ) => string
148
+
149
+ /**
150
+ * Narrows a framework's string-wide `t` (vue-i18n, i18next) to the generated
151
+ * map. Types only; returns `t` unchanged.
152
+ */
153
+ export function typedTranslate<Params extends TranslationParamsMap>(
154
+ t: (...args: never[]) => unknown
155
+ ): TypedTranslate<Params> {
156
+ return t as unknown as TypedTranslate<Params>
157
+ }
158
+
159
+ export * from './locales'
@@ -0,0 +1,135 @@
1
+ export type LocaleRegistry<L extends string> = {
2
+ /** Locale ids in declaration order. They name the catalog files. */
3
+ readonly locales: readonly L[]
4
+ readonly defaultLocale: L
5
+ tag(locale: L): string
6
+ name(locale: L): string
7
+ /**
8
+ * A supported locale for a BCP-47-ish tag (`'da'`, `'da-DK'`, `'en_US'`), or
9
+ * `null`. Matches the id, then the tag, then the language subtag, then the
10
+ * aliases.
11
+ */
12
+ match(tag: string | null | undefined): L | null
13
+ /** The first candidate that matches, else `defaultLocale`. */
14
+ resolve(...candidates: readonly (string | null | undefined)[]): L
15
+ }
16
+
17
+ /** The locale union of a registry: `LocaleOf<typeof locales>`. */
18
+ export type LocaleOf<R> = R extends LocaleRegistry<infer L> ? L : never
19
+
20
+ const normalizeTag = (tag: string) =>
21
+ tag.trim().replaceAll('_', '-').toLowerCase()
22
+
23
+ /**
24
+ * The app's shipped UI locales, shared by every runtime and the catalog check.
25
+ *
26
+ * ```ts
27
+ * export const uiLocales = defineLocales({
28
+ * locales: {
29
+ * en: { tag: 'en-US', name: 'English' },
30
+ * nb: { tag: 'nb-NO', name: 'Norsk bokmål' }
31
+ * },
32
+ * defaultLocale: 'en',
33
+ * aliases: { no: 'nb' }
34
+ * })
35
+ * uiLocales.match('no-NO') // 'nb'
36
+ * ```
37
+ */
38
+ export function defineLocales<
39
+ const T extends Record<
40
+ string,
41
+ {
42
+ /** BCP-47 tag for `Intl` and `<html lang>`, e.g. `'da-DK'`. */
43
+ tag: string
44
+ /** The language naming itself, for locale pickers: `'Dansk'`. */
45
+ name: string
46
+ }
47
+ >
48
+ >(options: {
49
+ locales: T
50
+ defaultLocale: keyof T & string
51
+ /** Extra tags or language subtags that mean a locale: `{ no: 'nb' }`. */
52
+ aliases?: Record<string, keyof T & string>
53
+ }): LocaleRegistry<keyof T & string> {
54
+ type L = keyof T & string
55
+ const { locales: info, defaultLocale, aliases = {} } = options
56
+ const locales = Object.keys(info) as L[]
57
+ const lookup = new Map<string, L>()
58
+ for (const locale of locales) lookup.set(normalizeTag(locale), locale)
59
+ for (const locale of locales) {
60
+ const tag = normalizeTag(info[locale]!.tag)
61
+ if (!lookup.has(tag)) lookup.set(tag, locale)
62
+ }
63
+ const aliasLookup = new Map<string, L>()
64
+ for (const [alias, locale] of Object.entries(aliases)) {
65
+ if (!Object.hasOwn(info, locale)) {
66
+ throw new Error(`Alias "${alias}" points at unknown locale "${locale}"`)
67
+ }
68
+ aliasLookup.set(normalizeTag(alias), locale)
69
+ }
70
+ if (!Object.hasOwn(info, defaultLocale)) {
71
+ throw new Error(`Default locale "${defaultLocale}" is not in locales`)
72
+ }
73
+
74
+ const match = (tag: string | null | undefined): L | null => {
75
+ if (!tag) return null
76
+ const full = normalizeTag(tag)
77
+ const language = full.split('-')[0] ?? ''
78
+ return (
79
+ lookup.get(full) ??
80
+ aliasLookup.get(full) ??
81
+ lookup.get(language) ??
82
+ aliasLookup.get(language) ??
83
+ null
84
+ )
85
+ }
86
+
87
+ return {
88
+ locales,
89
+ defaultLocale,
90
+ tag: (locale) => info[locale]!.tag,
91
+ name: (locale) => info[locale]!.name,
92
+ match,
93
+ resolve: (...candidates) => {
94
+ for (const candidate of candidates) {
95
+ const locale = match(candidate)
96
+ if (locale) return locale
97
+ }
98
+ return defaultLocale
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * A locale picker that switches immediately and rolls back when `persist`
105
+ * (for example saving the user's language on the server) rejects.
106
+ *
107
+ * ```ts
108
+ * const selectLocale = createLocaleSelector({
109
+ * getLocale: () => locale.value,
110
+ * setLocale,
111
+ * persist: (locale) => api.user.updateLocale({ locale }),
112
+ * onError: () => toast.error(t('settings.language.error'))
113
+ * })
114
+ * ```
115
+ */
116
+ export function createLocaleSelector<L extends string>(options: {
117
+ getLocale: () => L
118
+ setLocale: (locale: L) => unknown
119
+ persist?: (locale: L) => Promise<unknown>
120
+ onError?: (error: unknown) => void
121
+ }) {
122
+ const { getLocale, setLocale, persist, onError } = options
123
+ return async (next: L) => {
124
+ const previous = getLocale()
125
+ if (previous === next) return
126
+ await setLocale(next)
127
+ if (!persist) return
128
+ try {
129
+ await persist(next)
130
+ } catch (error) {
131
+ await setLocale(previous)
132
+ onError?.(error)
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,40 @@
1
+ import type { Plugin } from 'vite'
2
+
3
+ const INTLIFY_VIRTUAL_PREFIX = 'virtual:intlify-i18n-'
4
+
5
+ /**
6
+ * Dev-server plugin: reload the page with fresh catalogs when a
7
+ * `locales/*.json` file changes.
8
+ *
9
+ * `@intlify/unplugin-vue-i18n` serves each catalog as a `virtual:intlify-i18n-N`
10
+ * module that the module graph cannot tie back to its JSON file, and its own
11
+ * hot update only invalidates its bundle module, so an edited key kept serving
12
+ * the old catalog until the dev server restarted. This invalidates every
13
+ * catalog module and reloads: `createI18n` reads messages once at startup, so a
14
+ * hot swap would not reach the running instance anyway.
15
+ */
16
+ export function localeCatalogReloadPlugin(
17
+ options: {
18
+ /** Which changed files are catalogs. Default: `.json` in a `locales` folder. */
19
+ match?: RegExp
20
+ } = {}
21
+ ): Plugin {
22
+ const match = options.match ?? /[/\\]locales[/\\][^/\\]+\.json$/
23
+ return {
24
+ name: 'hanzio-locale-catalog-reload',
25
+ apply: 'serve',
26
+ hotUpdate({ file }) {
27
+ if (this.environment.name !== 'client') return
28
+ if (!match.test(file)) return
29
+
30
+ const graph = this.environment.moduleGraph
31
+ for (const [id, module] of graph.idToModuleMap) {
32
+ if (id.startsWith(INTLIFY_VIRTUAL_PREFIX))
33
+ graph.invalidateModule(module)
34
+ }
35
+
36
+ this.environment.hot.send({ type: 'full-reload' })
37
+ return []
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,82 @@
1
+ // vue-i18n bindings: a locale-registry-aware `createI18n` with a typed
2
+ // composer. Needs `vue-i18n` (and so `vue`).
3
+
4
+ import type { WritableComputedRef } from 'vue'
5
+ import { createI18n, type I18nOptions, useI18n } from 'vue-i18n'
6
+ import { createStorageItem, type SyncStorageLike } from '../storage'
7
+ import {
8
+ type LocaleRegistry,
9
+ type Messages,
10
+ type TranslationParamsMap,
11
+ typedTranslate
12
+ } from '.'
13
+
14
+ type BrowserGlobals = {
15
+ navigator?: { language?: string }
16
+ document?: { documentElement: { lang: string } }
17
+ }
18
+
19
+ const browser = globalThis as BrowserGlobals
20
+
21
+ /**
22
+ * `createI18n` in composition mode, starting in the persisted locale, then the
23
+ * browser language, then `locales.defaultLocale` (also the fallback locale).
24
+ * `setLocale` is the one write path: it switches vue-i18n, persists the choice
25
+ * and keeps `<html lang>` accurate. `useI18n<Params>()` is vue-i18n's composer
26
+ * with the locale and `t` narrowed to the registry and a generated key map;
27
+ * make it the one place the app calls vue-i18n's `useI18n`.
28
+ */
29
+ export function createVueI18n<L extends string>(
30
+ options: Omit<I18nOptions, 'legacy' | 'locale' | 'messages'> & {
31
+ locales: LocaleRegistry<L>
32
+ /** One catalog per locale; a missing locale is a type error. */
33
+ messages: Record<L, Messages>
34
+ /**
35
+ * Remembers the chosen locale (a raw string) so screens before sign-in
36
+ * come up in the right language. Omit to persist nothing.
37
+ */
38
+ storageKey?: string
39
+ /** Defaults to `localStorage`, falling back to memory. */
40
+ storage?: SyncStorageLike
41
+ }
42
+ ) {
43
+ const { locales, storageKey, storage, ...i18nOptions } = options
44
+ const persisted = storageKey
45
+ ? createStorageItem<string | null>({
46
+ key: storageKey,
47
+ fallback: null,
48
+ storage,
49
+ serialize: (value) => value ?? '',
50
+ deserialize: (raw) => raw
51
+ })
52
+ : undefined
53
+ const initial = locales.resolve(persisted?.get(), browser.navigator?.language)
54
+ const i18n = createI18n({
55
+ fallbackLocale: locales.defaultLocale,
56
+ ...i18nOptions,
57
+ legacy: false,
58
+ locale: initial
59
+ })
60
+
61
+ const apply = (locale: L) => {
62
+ ;(i18n.global.locale as WritableComputedRef<string>).value = locale
63
+ if (browser.document) browser.document.documentElement.lang = locale
64
+ }
65
+ apply(initial)
66
+
67
+ return {
68
+ i18n,
69
+ setLocale(locale: L) {
70
+ apply(locale)
71
+ persisted?.set(locale)
72
+ },
73
+ useI18n<Params extends TranslationParamsMap>() {
74
+ const composer = useI18n()
75
+ return {
76
+ ...composer,
77
+ locale: composer.locale as WritableComputedRef<L>,
78
+ t: typedTranslate<Params>(composer.t)
79
+ }
80
+ }
81
+ }
82
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Dependency-free helpers only. Modules that need zod, network access or a
2
2
  // specific runtime live on subpath exports (hanzio/zod, hanzio/api-wrapper,
3
3
  // hanzio/jwt, hanzio/sitemap, hanzio/p-queue, hanzio/cool-console-log,
4
- // hanzio/secrets).
4
+ // hanzio/secrets, hanzio/redis, hanzio/http, hanzio/testing).
5
5
  export * from './typedswitch'
6
6
  export * from './array'
7
7
  export * from './string'
@@ -0,0 +1,44 @@
1
+ # hanzio/iso
2
+
3
+ ISO codes as literal unions, with English names. Static data with no
4
+ dependencies, so it works where `Intl.DisplayNames` does not (Hermes). For
5
+ names in other languages, use `Intl.DisplayNames`.
6
+
7
+ ```ts
8
+ import {
9
+ countryCodes,
10
+ countryFlag,
11
+ countryNames,
12
+ currencyNames,
13
+ languageNames,
14
+ matchLanguageCode,
15
+ type CountryCode
16
+ } from 'hanzio/iso'
17
+
18
+ countryNames.DK // 'Denmark'
19
+ countryFlag('DK') // '🇩🇰'
20
+ currencyNames.EUR // 'Euro'
21
+ languageNames.nb // 'Norwegian Bokmål'
22
+ matchLanguageCode('da_DK') // 'da'
23
+
24
+ const options = countryCodes.map((code) => ({
25
+ value: code,
26
+ label: `${countryFlag(code)} ${countryNames[code]}`
27
+ }))
28
+ ```
29
+
30
+ | Data | Codes | Type and guard |
31
+ | --- | --- | --- |
32
+ | `countryNames` | All 249 ISO 3166-1 alpha-2 codes | `CountryCode`, `isCountryCode` |
33
+ | `currencyNames` | ISO 4217 codes in circulation (no historic codes, funds or metals) | `CurrencyCode`, `isCurrencyCode` |
34
+ | `languageNames` | All 183 ISO 639-1 codes, lowercase | `LanguageCode`, `isLanguageCode` |
35
+
36
+ `countryCodes`, `currencyCodes` and `languageCodes` list the keys.
37
+ `matchLanguageCode(tag)` returns the language subtag of a BCP-47-ish tag when
38
+ it is an ISO 639-1 code; map languages your app treats as one (`'nn'` to
39
+ `'nb'`, say) yourself.
40
+
41
+ `languageFromE164PhoneNumber(number)` guesses a conversation language from a
42
+ phone number's calling code (`'+4520304050'` → `'da'`). It covers the Nordics,
43
+ the UK, Ireland, North America and the larger Western European countries, and
44
+ returns `null` for anything else.