mnzipcode
v2.0.0
Published
Mongolian address and zipcode intelligence toolkit — offline lookup, search, OSM geocoding, and hybrid resolver
Maintainers
Readme
Features
- Offline-first — 2600+ zipcodes built in, no API keys, no network required
- Fuzzy search — Cyrillic & Latin, typo-tolerant
- Autocomplete — prefix-biased suggestions for search UIs
- OSM geocoding — optional forward & reverse geocoding via Nominatim
- Hybrid resolver — local dataset + OSM fallback with confidence scoring
- React hooks —
useZipcodeSearch,useResolveAddress,useReverseGeocode - Map component — drop-in
<ZipcodeMap />with click-to-resolve - TypeScript — full type definitions
- ESM + CJS — works everywhere
Install
npm install mnzipcodeQuick Start
import { lookup, isValid, search, suggest, resolve } from 'mnzipcode'
lookup('11000')
// → { resolved: true, zipcode: '11000', source: 'local', confidence: 1,
// normalized: { country: 'Mongolia', city: 'Ulaanbaatar' } }
isValid('11000') // true
isValid('99999') // false
search('Баянзүрх')
// → [{ zipcode: '13000', normalized: { city: 'Ulaanbaatar', district: 'Bayanzurkh' }, ... }]
suggest('Дор', { limit: 3 })
// → [{ name: 'Dornod', zipcode: '21000' }, { name: 'Dornogovi', zipcode: '44000' }, ...]
await resolve('Сүхбаатар дүүрэг', { mode: 'hybrid' })
// → { resolved: true, zipcode: '14000', source: 'hybrid', confidence: 0.9, ... }API Reference
Core (offline)
lookup(zipcode)
Exact zipcode lookup. Returns ResolveResult | null.
lookup('12001')
// → { zipcode: '12001', normalized: { country: 'Mongolia', city: 'Ulaanbaatar',
// district: 'Baganuur', subdistrict: 'Хэрлэн голын хөвөө-1' } }
lookup(21000) // numeric input works
lookup('99999') // nullisValid(zipcode)
Returns true if the zipcode exists in the dataset.
search(query, options?)
Fuzzy search across all Cyrillic and Latin names.
search('Баянзүрх', { limit: 5 })
search('Dornod')
search('Дорнот') // typo-tolerant| Option | Type | Default | Description |
| ------- | -------- | ------- | ----------- |
| limit | number | 10 | Max results |
suggest(query, options?)
Prefix-biased autocomplete suggestions.
suggest('Дор', { limit: 3 })
// → [{ name: 'Dornod', zipcode: '21000', confidence: 0.9 }, ...]OSM Geocoding (optional)
Forward and reverse geocoding via Nominatim. Opt-in — only called when you use them.
geocode(query, options?)
import { geocode } from 'mnzipcode'
await geocode('Ulaanbaatar', { userAgent: 'my-app/1.0' })
// → { resolved: true, source: 'osm', normalized: { city: 'Ulaanbaatar', lat: 47.9212, lon: 106.9057 } }reverse(lat, lon, options?)
import { reverse } from 'mnzipcode'
await reverse(47.9212, 106.9057)
// → { resolved: true, source: 'osm', normalized: { city: 'Ulaanbaatar', district: 'Sukhbaatar' } }OSM Options
| Option | Type | Default | Description |
| ------------- | --------- | ---------------- | ------------------------- |
| endpoint | string | Nominatim public | Custom Nominatim instance |
| userAgent | string | mnzipcode/2.0 | User-Agent header |
| cache | boolean | true | In-memory caching |
| cacheTTL | number | 3600000 | Cache TTL in ms (1h) |
| countryCode | string | mn | Country filter |
Hybrid Resolver
Combines local dataset + OSM into one unified call with confidence scoring.
resolve(input, options?)
import { resolve } from 'mnzipcode'
await resolve('11000', { mode: 'local' }) // offline, instant
await resolve('Peace Avenue', { mode: 'osm' }) // OSM only
await resolve('Баянзүрх', { mode: 'hybrid' }) // local first, OSM fallbackHow hybrid mode works:
Input
├─ looks like zipcode? → exact lookup → done
├─ fuzzy search local → confident match? → done
├─ fall back to OSM geocoding
├─ cross-reference with local data for zipcode
└─ return unified result with source: 'hybrid'| Option | Type | Default |
| ---------------- | ---------------------------------- | ---------- |
| mode | 'local' | 'osm' | 'hybrid' | 'hybrid' |
| osm | OsmOptions | — |
| fuzzyThreshold | number | 0.6 |
Output Schema
Every function returns a consistent ResolveResult:
interface ResolveResult {
input: string
resolved: boolean
zipcode?: string
confidence: number // 0–1
source: 'local' | 'osm' | 'hybrid'
normalized?: {
country?: string
city?: string
district?: string
subdistrict?: string
khoroo?: string
aimag?: string // province
soum?: string // sub-province
lat?: number
lon?: number
rawAddress?: string
}
candidates?: Array<{
name: string
zipcode?: string
source: 'local' | 'osm'
confidence: number
}>
}React / Next.js
Optional hooks and components via subpath imports. React is not required for the core package.
npm install mnzipcode reactuseZipcodeSearch
Debounced offline search for building search UIs.
import { useZipcodeSearch } from 'mnzipcode/react'
function ZipcodeSearch() {
const [query, setQuery] = useState('')
const { results, isSearching } = useZipcodeSearch(query, { debounceMs: 300, limit: 10 })
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
{results.map((r) => (
<div key={r.zipcode}>{r.zipcode} — {r.normalized?.district}</div>
))}
</div>
)
}useResolveAddress
Hybrid resolver with loading and error states.
import { useResolveAddress } from 'mnzipcode/react'
function AddressResolver() {
const [input, setInput] = useState('')
const { result, isLoading, error } = useResolveAddress(input, { mode: 'hybrid' })
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
{result?.resolved && <p>Zipcode: {result.zipcode} ({result.confidence * 100}%)</p>}
</div>
)
}useReverseGeocode
Coordinates to address + zipcode. Triggers when coordinates change.
import { useReverseGeocode } from 'mnzipcode/react'
function MapClickResult() {
const [coords, setCoords] = useState(null)
const { result, isLoading } = useReverseGeocode(coords?.lat ?? null, coords?.lon ?? null)
// On map click → setCoords({ lat, lon })
// result.zipcode → resolved zipcode
}Map Component
Drop-in interactive map with click-to-resolve. Requires leaflet and react-leaflet as peer dependencies.
npm install mnzipcode leaflet react-leafletimport { ZipcodeMap } from 'mnzipcode/map'
import 'leaflet/dist/leaflet.css'
function App() {
return (
<ZipcodeMap
height={500}
zoom={12}
center={[47.92, 106.91]}
onResolve={(result, coords) => {
console.log(result.zipcode, result.normalized)
}}
/>
)
}Click anywhere on the map → reverse geocode → zipcode + structured address.
<ZipcodeMap /> Props
| Prop | Type | Default | Description |
| ----------------- | ----------------------------- | ------------ | ------------------------------ |
| height | string \| number | '400px' | Map height |
| width | string \| number | '100%' | Map width |
| center | [number, number] | Mongolia | Initial center [lat, lon] |
| zoom | number | 6 | Initial zoom level |
| className | string | — | CSS class for wrapper div |
| style | CSSProperties | — | Inline style for wrapper |
| mapClassName | string | — | CSS class for map container |
| mapStyle | CSSProperties | — | Inline style for map |
| tileUrl | string | OSM default | Custom tile URL |
| tileAttribution | string | OSM | Tile attribution |
| onResolve | (result, coords) => void | — | Called when location resolved |
| onClick | (coords) => void | — | Called on map click |
| renderPopup | (result, loading, coords) => ReactNode | — | Custom popup content |
| markerIcon | L.Icon | Default pin | Custom marker icon |
| disabled | boolean | false | Disable click interaction |
With react-leaflet (manual)
If you prefer full control over the map instead of <ZipcodeMap />:
import { MapContainer, TileLayer, useMapEvents, Marker, Popup } from 'react-leaflet'
import { useReverseGeocode } from 'mnzipcode/react'
function LocationPicker() {
const [coords, setCoords] = useState(null)
const { result } = useReverseGeocode(coords?.lat ?? null, coords?.lon ?? null)
function ClickHandler() {
useMapEvents({ click(e) { setCoords({ lat: e.latlng.lat, lon: e.latlng.lng }) } })
return null
}
return (
<MapContainer center={[47.92, 106.91]} zoom={12} style={{ height: 400 }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<ClickHandler />
{coords && (
<Marker position={[coords.lat, coords.lon]}>
<Popup>{result?.normalized?.rawAddress}</Popup>
</Marker>
)}
</MapContainer>
)
}Package Exports
| Import | What you get | Requires |
| ------------------ | --------------------------------------------- | ------------------- |
| mnzipcode | Core: lookup, isValid, search, suggest, resolve, geocode, reverse | — |
| mnzipcode/react | Hooks: useZipcodeSearch, useResolveAddress, useReverseGeocode | react |
| mnzipcode/map | Component: <ZipcodeMap /> | react, leaflet, react-leaflet |
All peer dependencies are optional — install only what you use.
CommonJS
const { lookup, isValid, search } = require('mnzipcode')
lookup('11000')
isValid('21000')
search('Dornod')Dataset
2600+ Mongolian zipcodes covering all administrative levels:
| Level | Examples | Count | | ----------------- | ------------------------------------ | ----- | | Capital & Aimags | Ulaanbaatar, Dornod, Khentii | 22 | | Districts & Soums | Bayanzurkh, Baganuur, Khalkhgol | 340+ | | Sub-areas & Bags | Villages, neighborhoods, settlements | 2200+ |
Structure: Province/Capital > District/Soum > Sub-area
Each entry includes Cyrillic name. Top-level entries include Latin transliteration.
Contributing
git clone https://github.com/bekkaze/mnzipcode.git
cd mnzipcode
npm install
npm test
npm run build