summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:31:55 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:31:55 -0300
commit4ebb31f26efed74f8fcb049fe27ddf7cf5388a08 (patch)
treef21190d570ea0e33f730f96c89288b0b2c6656a9
parent986a16c34049187a6c1f39f38bfa59e327093494 (diff)
downloadtypescript-main.tar.gz
Carry the municipality of a CEP that does not existHEADv0.2.0main
A CEP that fails is rarely a random number: it is someone who knows the city and guessed the digits, and the classic guess is the -000 that 957 municipalities never received. The API now answers the 404 with the municipality that band belongs to, which is enough to tell the user "Areal/RJ, but not that number" instead of a bare failure. It stays an error. Faixa hangs off CEPNotFound rather than becoming a result, cep_ini/cep_fim are documented as the CEPs that exist there and not as a range to iterate, and an absent or unreadable block yields undefined so that context can never cost the caller the clean not-found it was already getting.
-rw-r--r--README.md41
-rw-r--r--package.json2
-rw-r--r--src/client.ts55
-rw-r--r--src/errors.ts18
-rw-r--r--src/index.ts6
-rw-r--r--src/models.ts99
-rw-r--r--src/version.ts2
-rw-r--r--test/cepis.test.ts126
8 files changed, 333 insertions, 16 deletions
diff --git a/README.md b/README.md
index 009f7df..71e68a1 100644
--- a/README.md
+++ b/README.md
@@ -192,6 +192,47 @@ try {
`RateLimited` exposes `retryAfterMs` and `ServerError` exposes `status`.
+### When the CEP does not exist
+
+`CEPNotFound` may carry a `faixa`: the municipality the CEP _would_ belong to.
+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.
+
+```ts
+import { CEPNotFound, lookup } from "cepis";
+
+try {
+ await lookup("25845000");
+} catch (error) {
+ if (error instanceof CEPNotFound && error.faixa) {
+ error.faixa.localidade; // "Areal"
+ error.faixa.uf; // "RJ"
+ error.faixa.ibge; // "3300225"
+ error.faixa.cep_ini; // "25845001"
+ error.faixa.cep_fim; // "25849899"
+ error.faixa.ceps; // 118
+ error.faixa.label; // "Areal/RJ"
+ }
+}
+```
+
+Three things about it:
+
+- **It is still a 404.** `faixa` is context for the error, never an address.
+ Showing "did you mean a CEP in Areal/RJ?" is the point; treating it as a
+ result is a wrong delivery.
+- **`cep_ini`/`cep_fim` are the CEPs that actually exist there**, not the range
+ the Correios publish. The DNE's own start for Areal is `25845000` — exactly
+ the CEP that just failed.
+- **The range is sparse.** Areal's 118 CEPs are scattered across the ~4,900
+ numbers between `25845001` and `25849899`. That is what `ceps` is for: the
+ pair is not a list of valid CEPs, and iterating it yields mostly 404s.
+
+When the CEP falls in a band no municipality owns, the API **omits** the field
+and `error.faixa` is `undefined` — never `null`. A missing or unreadable
+`faixa` never turns a clean not-found into a parse failure.
+
`InvalidResponse` almost always means something between you and the API is
answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML
error page. Useful as a diagnosis: the problem is not in your code.
diff --git a/package.json b/package.json
index 5958e69..cc38c08 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cepis",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "TypeScript client for the public CEP API at cep.is — official Correios data (DNE)",
"keywords": [
"cep",
diff --git a/src/client.ts b/src/client.ts
index c7dac15..3e48d7a 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -19,7 +19,7 @@ import {
RateLimited,
ServerError,
} from "./errors.js";
-import { Address, Health, type RawObject } from "./models.js";
+import { Address, Faixa, Health, type RawObject } from "./models.js";
import { VERSION } from "./version.js";
/** Where the public API lives. */
@@ -235,6 +235,19 @@ export class Client {
* message: the format already passed local validation, so the server knows
* something this client does not.
*
+ * A CEP that does not exist throws {@link CEPNotFound}, which may carry a
+ * {@link Faixa} naming the municipality the number would belong to:
+ *
+ * ```ts
+ * try {
+ * await client.lookup("25845000");
+ * } catch (error) {
+ * if (error instanceof CEPNotFound && error.faixa) {
+ * error.faixa.localidade; // "Areal" — still a 404, still no address
+ * }
+ * }
+ * ```
+ *
* @throws {InvalidCEP} the CEP is not 8 digits — no request is made.
* @throws {CEPNotFound} well-formed, but not in the database.
* @throws {RateLimited} 429 after every attempt.
@@ -247,7 +260,13 @@ export class Client {
const reply = await this.request(`/${number}`);
if (reply.status === 404) {
- throw new CEPNotFound(`CEP ${number} does not exist in the DNE database`, number);
+ const faixa = Faixa.fromJson(errorBody(reply.body)?.faixa);
+ const belongs = faixa === undefined ? "" : ` — that band belongs to ${faixa.label}`;
+ throw new CEPNotFound(
+ `CEP ${number} does not exist in the DNE database${belongs}`,
+ number,
+ faixa,
+ );
}
if (reply.status === 400) {
throw new InvalidCEP(errorMessage(reply.body, `CEP ${number} rejected`));
@@ -417,17 +436,31 @@ export class Client {
}
}
-function errorMessage(body: string, fallback: string): string {
+/**
+ * The body of an error response as an object, or `undefined` when it is not
+ * one.
+ *
+ * Never throws. An error response is already the bad news; a proxy answering
+ * with HTML in its place must not upgrade that into a second, different
+ * failure.
+ */
+function errorBody(body: string): RawObject | undefined {
+ let data: unknown;
try {
- const data: unknown = JSON.parse(body);
- if (typeof data === "object" && data !== null && !Array.isArray(data)) {
- const message = (data as RawObject).mensagem;
- if (typeof message === "string" && message !== "") {
- return message;
- }
- }
+ data = JSON.parse(body);
} catch {
- return fallback;
+ return undefined;
+ }
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
+ return undefined;
+ }
+ return data as RawObject;
+}
+
+function errorMessage(body: string, fallback: string): string {
+ const message = errorBody(body)?.mensagem;
+ if (typeof message === "string" && message !== "") {
+ return message;
}
return fallback;
}
diff --git a/src/errors.ts b/src/errors.ts
index 4e5dbb2..1a1e267 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -20,6 +20,8 @@
* @module
*/
+import type { Faixa } from "./models.js";
+
/**
* Base of every error this library throws. Catch it to handle any failure.
*
@@ -52,15 +54,29 @@ export class InvalidCEP extends CepisError {
*
* Never thrown by {@link Client.lookupMany}, which reports a missing CEP as
* `null` at its position instead.
+ *
+ * `faixa` says which municipality the CEP *would* belong to, when the number
+ * falls inside a band some municipality owns. It is context for the failure and
+ * nothing more — this is still a 404, and the CEP still does not exist.
*/
export class CEPNotFound extends CepisError {
/** The 8-digit CEP that was looked up. */
readonly cep: string;
+ /**
+ * The municipality the CEP would have belonged to, or `undefined` when it
+ * falls in a band no municipality owns.
+ *
+ * Read {@link Faixa} before using it: `cep_ini`/`cep_fim` are the CEPs that
+ * exist there, the range between them is sparse, and none of it makes the
+ * looked-up CEP valid.
+ */
+ readonly faixa?: Faixa;
- constructor(message: string, cep: string, options?: ErrorOptions) {
+ constructor(message: string, cep: string, faixa?: Faixa, options?: ErrorOptions) {
super(message, options);
this.name = "CEPNotFound";
this.cep = cep;
+ this.faixa = faixa;
}
}
diff --git a/src/index.ts b/src/index.ts
index 0ac9631..6acccb3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -34,7 +34,9 @@
* const address = await lookup(cepFromForm);
* } catch (error) {
* if (error instanceof CEPNotFound) {
- * // the CEP does not exist: ask the user for another
+ * // the CEP does not exist: ask the user for another. `error.faixa`, when
+ * // present, names the municipality it would have belonged to — context
+ * // for the message, not an address.
* } else if (error instanceof CepisError) {
* // network, server, rate limit: retry later
* } else {
@@ -70,9 +72,11 @@ export {
} from "./errors.js";
export {
Address,
+ Faixa,
Geo,
Health,
type AddressFields,
+ type FaixaFields,
type GeoFields,
type HealthFields,
type RawObject,
diff --git a/src/models.ts b/src/models.ts
index 7e20b54..7125336 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -246,6 +246,105 @@ export class Address {
}
}
+/** 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;
diff --git a/src/version.ts b/src/version.ts
index b024a5d..b370336 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
/** The library's version, as it appears in the default `User-Agent`. */
-export const VERSION = "0.1.0";
+export const VERSION = "0.2.0";
diff --git a/test/cepis.test.ts b/test/cepis.test.ts
index 911aa1d..67677fe 100644
--- a/test/cepis.test.ts
+++ b/test/cepis.test.ts
@@ -5,6 +5,7 @@ import {
CEPNotFound,
CepisError,
Client,
+ Faixa,
InvalidCEP,
InvalidResponse,
NetworkError,
@@ -31,6 +32,17 @@ const SE_ADDRESS: Record<string, unknown> = {
siafi: "7107",
};
+const AREAL_FAIXA: Record<string, unknown> = {
+ localidade: "Areal",
+ uf: "RJ",
+ estado: "Rio de Janeiro",
+ regiao: "Sudeste",
+ ibge: "3300225",
+ cep_ini: "25845001",
+ cep_fim: "25849899",
+ ceps: 118,
+};
+
const CSV_HEADER =
"cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi";
@@ -217,7 +229,119 @@ describe("lookup", () => {
} finally {
probe.stop(true);
}
- expect(seen[0]).toContain("cepis-typescript/0.1.0");
+ expect(seen[0]).toContain("cepis-typescript/0.2.0");
+ });
+});
+
+describe("faixa", () => {
+ async function notFound(cep: string): Promise<CEPNotFound> {
+ try {
+ await client.lookup(cep);
+ } catch (error) {
+ expect(error).toBeInstanceOf(CEPNotFound);
+ return error as CEPNotFound;
+ }
+ throw new Error("expected CEPNotFound");
+ }
+
+ test("names the municipality the CEP would belong to", async () => {
+ server.routes.respond("/25845000", {
+ status: 404,
+ body: JSON.stringify({
+ erro: "cep_nao_encontrado",
+ mensagem: "CEP não existe na base do DNE",
+ faixa: AREAL_FAIXA,
+ }),
+ });
+
+ const error = await notFound("25845000");
+ expect(error.faixa).toBeInstanceOf(Faixa);
+ expect(error.faixa?.localidade).toBe("Areal");
+ expect(error.faixa?.uf).toBe("RJ");
+ expect(error.faixa?.estado).toBe("Rio de Janeiro");
+ expect(error.faixa?.regiao).toBe("Sudeste");
+ expect(error.faixa?.ibge).toBe("3300225");
+ expect(error.faixa?.label).toBe("Areal/RJ");
+ });
+
+ test("the range is what exists there, and it is sparse", async () => {
+ server.routes.respond("/25845000", {
+ status: 404,
+ body: JSON.stringify({ erro: "cep_nao_encontrado", faixa: AREAL_FAIXA }),
+ });
+
+ const error = await notFound("25845000");
+ // The DNE's own start for Areal is 25845000 — the CEP that just failed.
+ expect(error.faixa?.cep_ini).toBe("25845001");
+ expect(error.faixa?.cep_fim).toBe("25849899");
+ // 118 CEPs across ~4,900 numbers: the pair is not a list of valid CEPs.
+ expect(error.faixa?.ceps).toBe(118);
+ });
+
+ test("a faixa is still a not-found, never an address", async () => {
+ server.routes.respond("/25845000", {
+ status: 404,
+ body: JSON.stringify({ erro: "cep_nao_encontrado", faixa: AREAL_FAIXA }),
+ });
+
+ const promise = client.lookup("25845000");
+ await expect(promise).rejects.toBeInstanceOf(CEPNotFound);
+ await expect(promise).rejects.toThrow("25845000");
+ await expect(promise).rejects.toThrow("Areal/RJ");
+ expect(server.routes.count("/25845000")).toBe(1);
+ });
+
+ test("a band no municipality owns omits the field", async () => {
+ server.routes.respond("/99999999", {
+ status: 404,
+ body: JSON.stringify({
+ erro: "cep_nao_encontrado",
+ mensagem: "CEP não existe na base do DNE",
+ }),
+ });
+
+ const error = await notFound("99999999");
+ expect(error.faixa).toBeUndefined();
+ expect(error.cep).toBe("99999999");
+ expect(error.message).not.toContain("belongs to");
+ });
+
+ test("a faixa that cannot be read costs nothing", async () => {
+ const useless: unknown[] = [null, "Areal", [], {}, { localidade: "" }, { uf: "RJ" }];
+ for (const faixa of useless) {
+ server.routes.respond("/99999999", {
+ status: 404,
+ body: JSON.stringify({ erro: "cep_nao_encontrado", faixa }),
+ });
+ const error = await notFound("99999999");
+ expect(error.faixa).toBeUndefined();
+ }
+ });
+
+ test("a 404 that is not JSON still yields a clean not-found", async () => {
+ server.routes.respond("/99999999", {
+ status: 404,
+ body: "<html>captive portal</html>",
+ });
+
+ const error = await notFound("99999999");
+ expect(error.faixa).toBeUndefined();
+ });
+
+ test("a partial faixa keeps what it can", async () => {
+ server.routes.respond("/99999999", {
+ status: 404,
+ body: JSON.stringify({
+ erro: "cep_nao_encontrado",
+ faixa: { localidade: "Areal", ceps: "118" },
+ }),
+ });
+
+ const error = await notFound("99999999");
+ expect(error.faixa?.localidade).toBe("Areal");
+ expect(error.faixa?.label).toBe("Areal");
+ expect(error.faixa?.ceps).toBe(118);
+ expect(error.faixa?.cep_ini).toBe("");
});
});