/** * The data the API returns, as objects. * * **Data field names are the API's own, in Portuguese** — `logradouro`, * `bairro`, `localidade`. Everything else in this library is English; the data * fields are the one deliberate exception. * * The reason is fidelity: what you read in the JSON at * https://cep.is/01001000 is exactly what you access on the object. * Translating to `street`/`city` would mean maintaining a field-by-field * mapping and forcing anyone reading the API docs alongside this library to * translate back in their head. The values are Portuguese either way — they * come from the Correios database. * * @module */ /** A JSON object as it arrived from the API, before interpretation. */ export type RawObject = Record; function toNumber(value: unknown): number | null { if (typeof value === "number") { return Number.isFinite(value) ? value : null; } if (typeof value === "string" && value.trim() !== "") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function toText(value: unknown): string { if (value === undefined || value === null) { return ""; } return String(value); } /** The four values that make up a coordinate. */ export interface GeoFields { lat: number; lon: number; raio_m: number; precisao: string; } /** * The CEP's coordinate, when the database has one. * * The point is the **median** of the coordinates of that CEP's addresses, not * the mean: one badly geocoded address 40 km away shifts a mean and does not * move a median. * * Always check `precisao` before using `lat`/`lon` for distance: * * - `"logradouro"`: the point is a stretch of street. `raio_m` lands around * 100–150 m. Good for maps and for distance. * - `"localidade"`: the CEP *is* the whole municipality (a city-wide general * CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km. * Computing shipping from this is wrong by tens of kilometres. * * `raio_m` is the radius containing 90% of that CEP's addresses. It exists * precisely so that error cannot pass unnoticed. */ export class Geo implements GeoFields { /** Latitude, in decimal degrees. */ readonly lat: number; /** Longitude, in decimal degrees. */ readonly lon: number; /** Radius, in metres, containing 90% of that CEP's addresses. */ readonly raio_m: number; /** Either `"logradouro"` (a street) or `"localidade"` (a municipality). */ readonly precisao: string; constructor(fields: GeoFields) { this.lat = fields.lat; this.lon = fields.lon; this.raio_m = fields.raio_m; this.precisao = fields.precisao; } /** * `true` when the coordinate points at a street, not a municipality. * * Shorthand for `precisao === "logradouro"`, which is the check to make * before using the point to measure distance. */ get isPrecise(): boolean { return this.precisao === "logradouro"; } /** * Build from the `geo` block the API returned, or `null` when it cannot be * read. * * A malformed block yields `null` rather than throwing: it is not worth * losing the whole address over, since the rest of the response is still * useful. */ static fromJson(data: unknown): Geo | null { if (typeof data !== "object" || data === null || Array.isArray(data)) { return null; } const block = data as RawObject; const lat = toNumber(block.lat); const lon = toNumber(block.lon); const raio = toNumber(block.raio_m); if (lat === null || lon === null || raio === null) { return null; } if (block.precisao === undefined || block.precisao === null) { return null; } return new Geo({ lat, lon, raio_m: Math.trunc(raio), precisao: String(block.precisao), }); } } /** Every field of an {@link Address}, as plain data. */ export interface AddressFields { cep: string; logradouro: string; complemento: string; unidade: string; bairro: string; localidade: string; uf: string; estado: string; regiao: string; ibge: string; gia: string; ddd: string; siafi: string; geo?: Geo | null; raw?: RawObject; } /** * A CEP and everything the database knows about it. * * Fields the database does not have for that CEP come back as empty strings, * never `null` — that is what the API returns, and changing it here would * create a difference between reading the JSON and reading this library. * * `geo` is the exception: it is `null` when there is no coordinate. There the * difference is real, because the API **omits** the whole field in that case * (about 43% of CEPs) rather than sending null. * * `raw` holds the response exactly as it arrived, uninterpreted, so a field * the API grows before this library is updated is not lost. */ export class Address { /** The 8 digits, always unmasked. */ readonly cep: string; /** Street, avenue or square. Empty when the CEP covers a whole municipality. */ readonly logradouro: string; /** Extra qualifier on the street, such as `"lado ímpar"`. */ readonly complemento: string; /** Name of the large recipient the CEP belongs to, when it has one. */ readonly unidade: string; /** Neighbourhood. */ readonly bairro: string; /** Municipality. */ readonly localidade: string; /** Two-letter state code, such as `"SP"`. */ readonly uf: string; /** State name, spelled out. */ readonly estado: string; /** Region of the country, such as `"Sudeste"`. */ readonly regiao: string; /** IBGE municipality code. */ readonly ibge: string; /** GIA code, for São Paulo state. */ readonly gia: string; /** Telephone area code. */ readonly ddd: string; /** SIAFI municipality code. */ readonly siafi: string; /** * The coordinate, or `null` when the database has none. * * Always `null` for an address obtained through `Client.lookupMany` — * batches carry no coordinate. */ readonly geo: Geo | null; /** The response as it arrived, including fields this library does not know. */ readonly raw: RawObject; constructor(fields: AddressFields) { this.cep = fields.cep; this.logradouro = fields.logradouro; this.complemento = fields.complemento; this.unidade = fields.unidade; this.bairro = fields.bairro; this.localidade = fields.localidade; this.uf = fields.uf; this.estado = fields.estado; this.regiao = fields.regiao; this.ibge = fields.ibge; this.gia = fields.gia; this.ddd = fields.ddd; this.siafi = fields.siafi; this.geo = fields.geo ?? null; this.raw = fields.raw ?? {}; } /** * The CEP with its mask: `01001-000`. * * The native contract always returns it unmasked; formatting is * presentation, and it lives here so it does not become a repeated template * literal in every project that uses this. */ get formattedCep(): string { return `${this.cep.slice(0, 5)}-${this.cep.slice(5)}`; } /** * Build from the object the API returned. * * Unknown fields are ignored during construction but preserved in `raw`, so * a new field in the API cannot break this library. */ static fromJson(data: RawObject): Address { return new Address({ cep: toText(data.cep), logradouro: toText(data.logradouro), complemento: toText(data.complemento), unidade: toText(data.unidade), bairro: toText(data.bairro), localidade: toText(data.localidade), uf: toText(data.uf), estado: toText(data.estado), regiao: toText(data.regiao), ibge: toText(data.ibge), gia: toText(data.gia), ddd: toText(data.ddd), siafi: toText(data.siafi), geo: Geo.fromJson(data.geo), raw: { ...data }, }); } } /** Every field of a {@link Faixa}, as plain data. */ export interface FaixaFields { localidade: string; uf: string; estado: string; regiao: string; ibge: string; cep_ini: string; cep_fim: string; ceps: number; } /** * The municipality a CEP that does not exist *would* belong to. * * It comes attached to {@link CEPNotFound}, on a response that is still a * **404**. It is context for an error, never an address: nothing here says the * CEP exists, and code that treats a `faixa` as a result turns a clean * not-found into a wrong delivery. * * It exists because a failing CEP is rarely random — it is someone who knows * the city and guessed the number, and the classic guess is the `-000` that 957 * municipalities never received. * * `cep_ini` and `cep_fim` are the smallest and the largest CEP that **actually * exist** in that municipality, not the range the Correios publish for it. For * Areal/RJ the DNE's own start is `25845000` — precisely the CEP that just * failed — while `cep_ini` is `25845001`. * * The range is **sparse**. Areal's 118 CEPs are scattered across the ~4,900 * numbers between `25845001` and `25849899`, so the pair is not a list of valid * CEPs and iterating it produces mostly 404s. `ceps` is there to make that * density visible: 118 out of 4,900. */ export class Faixa implements FaixaFields { /** Municipality that owns the band the CEP falls in. */ readonly localidade: string; /** Two-letter state code, such as `"RJ"`. */ readonly uf: string; /** State name, spelled out. */ readonly estado: string; /** Region of the country, such as `"Sudeste"`. */ readonly regiao: string; /** IBGE municipality code. */ readonly ibge: string; /** Smallest CEP that exists there — not the band's published start. */ readonly cep_ini: string; /** Largest CEP that exists there — not the band's published end. */ readonly cep_fim: string; /** How many CEPs actually exist between `cep_ini` and `cep_fim`. */ readonly ceps: number; constructor(fields: FaixaFields) { this.localidade = fields.localidade; this.uf = fields.uf; this.estado = fields.estado; this.regiao = fields.regiao; this.ibge = fields.ibge; this.cep_ini = fields.cep_ini; this.cep_fim = fields.cep_fim; this.ceps = fields.ceps; } /** The municipality as `"Areal/RJ"`, for a message shown to a person. */ get label(): string { return this.uf === "" ? this.localidade : `${this.localidade}/${this.uf}`; } /** * Build from the `faixa` block of a 404 body, or `undefined` when there is * none to read. * * `undefined`, not `null`, because the API **omits** the field rather than * sending null when the CEP falls in a band no municipality owns. Absent and * unreadable are the same answer here: a malformed block must never cost the * caller the not-found error it was already getting. */ static fromJson(data: unknown): Faixa | undefined { if (typeof data !== "object" || data === null || Array.isArray(data)) { return undefined; } const block = data as RawObject; const localidade = toText(block.localidade).trim(); if (localidade === "") { return undefined; } return new Faixa({ localidade, uf: toText(block.uf), estado: toText(block.estado), regiao: toText(block.regiao), ibge: toText(block.ibge), cep_ini: toText(block.cep_ini), cep_fim: toText(block.cep_fim), ceps: toNumber(block.ceps) ?? 0, }); } } /** Every field of a {@link Health} report, as plain data. */ export interface HealthFields { status: string; dne: string; ceps: number; geo: number; base_gerada_em: string; } /** * The response of `GET /health`. * * Good for two things: checking that the API answers, and knowing **which * version of the database** is live. The DNE database changes every two weeks; * `dne` identifies it. */ export class Health implements HealthFields { /** `"ok"` when the API considers itself healthy. */ readonly status: string; /** Identifier of the fortnightly DNE database currently loaded. */ readonly dne: string; /** How many CEPs that database holds. */ readonly ceps: number; /** How many of those carry a coordinate. */ readonly geo: number; /** When the base was generated, as an ISO-8601 instant. */ readonly base_gerada_em: string; constructor(fields: HealthFields) { this.status = fields.status; this.dne = fields.dne; this.ceps = fields.ceps; this.geo = fields.geo; this.base_gerada_em = fields.base_gerada_em; } /** `true` when the API reports itself healthy. */ get ok(): boolean { return this.status === "ok"; } /** Build from the object the API returned. */ static fromJson(data: RawObject): Health { return new Health({ status: toText(data.status), dne: toText(data.dne), ceps: toNumber(data.ceps) ?? 0, geo: toNumber(data.geo) ?? 0, base_gerada_em: toText(data.base_gerada_em), }); } }