From ff7228a09b9cb7ad3b79e7e6f917d698aa719819 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:56:30 -0300 Subject: [PATCH 1/3] feat(get-state-by-cep): add getStateByCep, the state that owns a CEP range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knowing the state of a CEP so far took a network call to a CEP API. The Correios assign every state one or two ranges of CEPs, so the state can be answered offline from a 30 row table: Amazonas, Distrito Federal and Goiás have two ranges each, and 00000-000 to 00999-999 and 78900-000 to 78999-999 belong to no state and answer null. The value goes through isValidCep and parseCep, and a number has to be a non-negative integer, as in getStateByIbgeCode. The result is the same State object the other state utils return. --- docs/pt-br/utilities.md | 26 +++ docs/utilities.md | 26 +++ jsr.json | 1 + reports/api/brazilian-utils.api.md | 3 + src/get-state-by-cep/constants.ts | 55 ++++++ src/get-state-by-cep/get-state-by-cep.test.ts | 167 ++++++++++++++++++ src/get-state-by-cep/get-state-by-cep.ts | 56 ++++++ src/index.test.ts | 1 + src/index.ts | 1 + 9 files changed, 336 insertions(+) create mode 100644 src/get-state-by-cep/constants.ts create mode 100644 src/get-state-by-cep/get-state-by-cep.test.ts create mode 100644 src/get-state-by-cep/get-state-by-cep.ts diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 5736ff28d..414ba1513 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -1492,6 +1492,32 @@ getStates(); Fonte: [IBGE Localidades](https://servicodados.ibge.gov.br/api/docs/localidades) +### getStateByCep + +Retorna o estado brasileiro ao qual um CEP pertence, a partir das faixas de CEP que os Correios atribuem a cada UF (a "Faixa de CEP" de cada UF). + +- Funciona offline: nenhuma API de CEP é chamada, então a resposta diz qual estado é dono da faixa, não se o CEP está em uso. +- Aceita o que o `isValidCep` aceita: 8 dígitos, como string ou número, ignorando espaços, pontos e hifens. Um CEP que começa com `0` precisa ser uma string, e um número negativo ou fracionário é rejeitado. +- Amazonas, Distrito Federal e Goiás têm duas faixas cada, e nenhum estado é dono de `00000-000` a `00999-999` nem de `78900-000` a `78999-999`. +- Retorna `null` para um CEP inválido ou fora de todas as faixas. Exporta o tipo `State`. + +```javascript +import { getStateByCep } from '@brazilian-utils/brazilian-utils'; + +getStateByCep('01310-100'); +// { code: 'SP', name: 'São Paulo', regionCode: 'SE', regionName: 'Sudeste', ibgeCode: 35 } + +getStateByCep(20040020); +// { code: 'RJ', name: 'Rio de Janeiro', regionCode: 'SE', regionName: 'Sudeste', ibgeCode: 33 } + +getStateByCep('69300-000')?.code; // 'RR' +getStateByCep('72800-000')?.code; // 'GO' +getStateByCep('00999-999'); // null +getStateByCep('12345'); // null +``` + +Fonte: [Correios, Busca Faixa de CEP](https://buscacepinter.correios.com.br/app/faixa_cep_uf_localidade/index.php) + ### getStateByIbgeCode Retorna o estado brasileiro cujo código IBGE de 2 dígitos (`cUF`, o Código da Unidade da Federação) corresponde ao valor informado. diff --git a/docs/utilities.md b/docs/utilities.md index dc8b8b3e9..8c157a4e1 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -1492,6 +1492,32 @@ getStates(); Source: [IBGE Localidades](https://servicodados.ibge.gov.br/api/docs/localidades) +### getStateByCep + +Get the Brazilian state a CEP belongs to, from the CEP ranges the Correios assign to each state (the "Faixa de CEP" of each UF). + +- It runs offline: no CEP API is called, so the answer says which state owns the range, not whether the CEP is in use. +- Accepts what `isValidCep` accepts: 8 digits, as a string or a number, with spaces, dots and hyphens ignored. A CEP that starts with `0` has to be a string, and a negative or fractional number is rejected. +- Amazonas, Distrito Federal and Goiás have two ranges each, and no state owns `00000-000` to `00999-999` nor `78900-000` to `78999-999`. +- Returns `null` for an invalid CEP or one outside every range. Exports the `State` type. + +```javascript +import { getStateByCep } from '@brazilian-utils/brazilian-utils'; + +getStateByCep('01310-100'); +// { code: 'SP', name: 'São Paulo', regionCode: 'SE', regionName: 'Sudeste', ibgeCode: 35 } + +getStateByCep(20040020); +// { code: 'RJ', name: 'Rio de Janeiro', regionCode: 'SE', regionName: 'Sudeste', ibgeCode: 33 } + +getStateByCep('69300-000')?.code; // 'RR' +getStateByCep('72800-000')?.code; // 'GO' +getStateByCep('00999-999'); // null +getStateByCep('12345'); // null +``` + +Source: [Correios, Busca Faixa de CEP](https://buscacepinter.correios.com.br/app/faixa_cep_uf_localidade/index.php) + ### getStateByIbgeCode Get the Brazilian state whose 2-digit IBGE code (`cUF`, the Código da Unidade da Federação) matches the given value. diff --git a/jsr.json b/jsr.json index e8ceb850d..fbdd94b7b 100644 --- a/jsr.json +++ b/jsr.json @@ -76,6 +76,7 @@ "./get-nfe-key-info": "./src/get-nfe-key-info/get-nfe-key-info.ts", "./get-pix-key-info": "./src/get-pix-key-info/get-pix-key-info.ts", "./get-pix-payload-info": "./src/get-pix-payload-info/get-pix-payload-info.ts", + "./get-state-by-cep": "./src/get-state-by-cep/get-state-by-cep.ts", "./get-state-by-ibge-code": "./src/get-state-by-ibge-code/get-state-by-ibge-code.ts", "./get-state-code-by-name": "./src/get-state-code-by-name/get-state-code-by-name.ts", "./get-state-name-by-code": "./src/get-state-name-by-code/get-state-name-by-code.ts", diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 8539554b2..56e66f80c 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -611,6 +611,9 @@ export const getPixKeyInfo: (value: string) => PixKeyInfo | null; // @public export const getPixPayloadInfo: (value: string) => PixPayloadInfo | null; +// @public +export const getStateByCep: (value: string | number) => State | null; + // @public export const getStateByIbgeCode: (code: string | number) => State | null; diff --git a/src/get-state-by-cep/constants.ts b/src/get-state-by-cep/constants.ts new file mode 100644 index 000000000..262676229 --- /dev/null +++ b/src/get-state-by-cep/constants.ts @@ -0,0 +1,55 @@ +import { type StateCode } from "../_internals/constants/states"; + +/** One range of CEPs assigned by the Correios to a state. */ +type CepRange = { + /** Two letter code of the state that owns the range. */ + readonly state: StateCode; + /** First CEP of the range, as a number. */ + readonly start: number; + /** Last CEP of the range, as a number. */ + readonly end: number; +}; + +/** + * CEP ranges of each state ("Faixa de CEP" per UF), as answered by the Correios "Busca Faixa de + * CEP" search when only the UF is given, in ascending order. Amazonas, Distrito Federal and Goiás + * have two ranges each: Roraima sits inside the Amazonas block and the Goiás municipalities + * around Brasília sit inside the Distrito Federal block. No state owns `00000-000` to + * `00999-999` nor `78900-000` to `78999-999`. + * + * @see Official: https://buscacepinter.correios.com.br/app/faixa_cep_uf_localidade/index.php + * @see Based on: https://gist.github.com/tamnil/792a6a66f6df9fc028041587cfca0c3d + * Copy of the answers of the Correios search, the rows with an empty city are the state ranges. + */ +export const CEP_RANGES: readonly CepRange[] = [ + { state: "SP", start: 1_000_000, end: 19_999_999 }, + { state: "RJ", start: 20_000_000, end: 28_999_999 }, + { state: "ES", start: 29_000_000, end: 29_999_999 }, + { state: "MG", start: 30_000_000, end: 39_999_999 }, + { state: "BA", start: 40_000_000, end: 48_999_999 }, + { state: "SE", start: 49_000_000, end: 49_999_999 }, + { state: "PE", start: 50_000_000, end: 56_999_999 }, + { state: "AL", start: 57_000_000, end: 57_999_999 }, + { state: "PB", start: 58_000_000, end: 58_999_999 }, + { state: "RN", start: 59_000_000, end: 59_999_999 }, + { state: "CE", start: 60_000_000, end: 63_999_999 }, + { state: "PI", start: 64_000_000, end: 64_999_999 }, + { state: "MA", start: 65_000_000, end: 65_999_999 }, + { state: "PA", start: 66_000_000, end: 68_899_999 }, + { state: "AP", start: 68_900_000, end: 68_999_999 }, + { state: "AM", start: 69_000_000, end: 69_299_999 }, + { state: "RR", start: 69_300_000, end: 69_399_999 }, + { state: "AM", start: 69_400_000, end: 69_899_999 }, + { state: "AC", start: 69_900_000, end: 69_999_999 }, + { state: "DF", start: 70_000_000, end: 72_799_999 }, + { state: "GO", start: 72_800_000, end: 72_999_999 }, + { state: "DF", start: 73_000_000, end: 73_699_999 }, + { state: "GO", start: 73_700_000, end: 76_799_999 }, + { state: "RO", start: 76_800_000, end: 76_999_999 }, + { state: "TO", start: 77_000_000, end: 77_999_999 }, + { state: "MT", start: 78_000_000, end: 78_899_999 }, + { state: "MS", start: 79_000_000, end: 79_999_999 }, + { state: "PR", start: 80_000_000, end: 87_999_999 }, + { state: "SC", start: 88_000_000, end: 89_999_999 }, + { state: "RS", start: 90_000_000, end: 99_999_999 }, +]; diff --git a/src/get-state-by-cep/get-state-by-cep.test.ts b/src/get-state-by-cep/get-state-by-cep.test.ts new file mode 100644 index 000000000..641465429 --- /dev/null +++ b/src/get-state-by-cep/get-state-by-cep.test.ts @@ -0,0 +1,167 @@ +import * as fc from "fast-check"; + +import { type State } from "../_internals/constants/states"; +import { anyGarbage, digits, digitsOfOtherLength } from "../_internals/test/arbitraries"; +import { expectNeverThrows } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { getStateByCep } from "./get-state-by-cep"; + +const RANGE_BOUNDARIES: [string, string, string][] = [ + ["SP", "01000000", "19999999"], + ["RJ", "20000000", "28999999"], + ["ES", "29000000", "29999999"], + ["MG", "30000000", "39999999"], + ["BA", "40000000", "48999999"], + ["SE", "49000000", "49999999"], + ["PE", "50000000", "56999999"], + ["AL", "57000000", "57999999"], + ["PB", "58000000", "58999999"], + ["RN", "59000000", "59999999"], + ["CE", "60000000", "63999999"], + ["PI", "64000000", "64999999"], + ["MA", "65000000", "65999999"], + ["PA", "66000000", "68899999"], + ["AP", "68900000", "68999999"], + ["AM", "69000000", "69299999"], + ["RR", "69300000", "69399999"], + ["AM", "69400000", "69899999"], + ["AC", "69900000", "69999999"], + ["DF", "70000000", "72799999"], + ["GO", "72800000", "72999999"], + ["DF", "73000000", "73699999"], + ["GO", "73700000", "76799999"], + ["RO", "76800000", "76999999"], + ["TO", "77000000", "77999999"], + ["MT", "78000000", "78899999"], + ["MS", "79000000", "79999999"], + ["PR", "80000000", "87999999"], + ["SC", "88000000", "89999999"], + ["RS", "90000000", "99999999"], +]; + +describe("getStateByCep", () => { + it("should return São Paulo for a formatted CEP of Avenida Paulista", () => { + expect(getStateByCep("01310-100")).toEqual({ + code: "SP", + name: "São Paulo", + regionCode: "SE", + regionName: "Sudeste", + ibgeCode: 35, + }); + }); + + it("should return Rio de Janeiro for a CEP given as a number", () => { + expect(getStateByCep(20_040_020)).toEqual({ + code: "RJ", + name: "Rio de Janeiro", + regionCode: "SE", + regionName: "Sudeste", + ibgeCode: 33, + }); + }); + + it("should accept the punctuated forms isValidCep accepts", () => { + expect(getStateByCep("92.500-000")?.code).toBe("RS"); + expect(getStateByCep("70 040 010")?.code).toBe("DF"); + }); + + it("should return the owner of the first and of the last CEP of every range", () => { + for (const [code, first, last] of RANGE_BOUNDARIES) { + expect(getStateByCep(first)?.code).toBe(code); + expect(getStateByCep(last)?.code).toBe(code); + } + }); + + it("should return a CEP in the middle of a range", () => { + expect(getStateByCep("69050000")?.code).toBe("AM"); + expect(getStateByCep("69650000")?.code).toBe("AM"); + expect(getStateByCep("71000000")?.code).toBe("DF"); + expect(getStateByCep("73350000")?.code).toBe("DF"); + expect(getStateByCep("72900000")?.code).toBe("GO"); + expect(getStateByCep("74000000")?.code).toBe("GO"); + }); + + it("should return null for the CEPs below the first range", () => { + expect(getStateByCep("00000000")).toBeNull(); + expect(getStateByCep("00999999")).toBeNull(); + }); + + it("should return null for the gap between Mato Grosso and Mato Grosso do Sul", () => { + expect(getStateByCep("78900000")).toBeNull(); + expect(getStateByCep("78950000")).toBeNull(); + expect(getStateByCep("78999999")).toBeNull(); + }); + + it("should return a fresh copy that does not mutate the underlying constant", () => { + const state = getStateByCep("01310100"); + if (state) Object.assign(state, { name: "X" }); + + expect(getStateByCep("01310100")?.name).toBe("São Paulo"); + }); + + it("should return null for a CEP with the wrong length", () => { + expect(getStateByCep("12345")).toBeNull(); + expect(getStateByCep("013101000")).toBeNull(); + expect(getStateByCep(1_310_100)).toBeNull(); + expect(getStateByCep("")).toBeNull(); + }); + + it("should return null for a value with a letter, not read its digits alone", () => { + expect(getStateByCep("abc01310100")).toBeNull(); + expect(getStateByCep("01310-10a")).toBeNull(); + }); + + it("should return null for a negative or a fractional number", () => { + expect(getStateByCep(-20_040_020)).toBeNull(); + expect(getStateByCep(2_004_002.5)).toBeNull(); + }); + + it("should return null for values that are not a string or a number", () => { + // @ts-expect-error: intentionally invalid input + expect(getStateByCep(null)).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(getStateByCep()).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(getStateByCep(["01310100"])).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(getStateByCep({ toString: () => "01310100" })).toBeNull(); + expect(getStateByCep("__proto__")).toBeNull(); + }); + + describe("properties", () => { + test("should never throw, regardless of the input", () => { + expectNeverThrows(getStateByCep, anyGarbage); + }); + + test("should return null for digits of any length other than 8", () => { + fc.assert( + fc.property(digitsOfOtherLength(12, [8]), (value) => { + expect(getStateByCep(value)).toBeNull(); + }), + ); + }); + + test("should answer the same for a CEP with and without its hyphen", () => { + fc.assert( + fc.property(digits(8), (cep) => { + expect(getStateByCep(`${cep.slice(0, 5)}-${cep.slice(5)}`)).toEqual(getStateByCep(cep)); + }), + ); + }); + + test("should place every CEP from 80000-000 up in the Sul region", () => { + fc.assert( + fc.property(fc.integer({ min: 80_000_000, max: 99_999_999 }), (cep) => { + expect(getStateByCep(cep)?.regionCode).toBe("S"); + }), + ); + }); + }); +}); + +describe("getStateByCep types", () => { + test("should take a string or number and return a State or null", () => { + expectTypeOf(getStateByCep).parameter(0).toEqualTypeOf(); + expectTypeOf(getStateByCep).returns.toEqualTypeOf(); + }); +}); diff --git a/src/get-state-by-cep/get-state-by-cep.ts b/src/get-state-by-cep/get-state-by-cep.ts new file mode 100644 index 000000000..eb42f9f2c --- /dev/null +++ b/src/get-state-by-cep/get-state-by-cep.ts @@ -0,0 +1,56 @@ +import { DATA, type State } from "../_internals/constants/states"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { isValidCep } from "../is-valid-cep/is-valid-cep"; +import { parseCep } from "../parse-cep/parse-cep"; +import { CEP_RANGES } from "./constants"; + +export type { State } from "../_internals/constants/states"; + +/** + * Retrieves the Brazilian state a CEP (postal code) belongs to, from the CEP ranges the Correios + * assign to each state. It runs offline: the answer comes from the range table, not from a CEP + * API, so it says which state owns the range and not whether the CEP is in use. + * + * The value is accepted under the same rules as `isValidCep`: 8 digits, as a string or a number, + * with spaces, dots and hyphens ignored. A number cannot carry a leading zero, so a CEP of São + * Paulo that starts with `0` has to be given as a string. A number must also be a non-negative + * integer: a sign and a decimal point are not digits, so `-20040020` and `2004002.5` are rejected + * instead of being read as a CEP. + * + * Amazonas, Distrito Federal and Goiás have two ranges each. No state owns `00000-000` to + * `00999-999` nor `78900-000` to `78999-999`, so a CEP in one of them returns `null`. + * + * @param {string|number} value - The CEP, with or without formatting. + * @returns {State|null} The matching `State` object, or `null` when the value is not a valid CEP + * or falls outside every range. + * + * @see Official: https://buscacepinter.correios.com.br/app/faixa_cep_uf_localidade/index.php + * Correios "Busca Faixa de CEP": a search by UF alone answers the ranges of the state. + * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep + * @see Based on: https://gist.github.com/tamnil/792a6a66f6df9fc028041587cfca0c3d + * Copy of the answers of the Correios search, the rows with an empty city are the state ranges. + * + * @example + * ```typescript + * getStateByCep("01310-100"); // { code: "SP", name: "São Paulo", regionCode: "SE", regionName: "Sudeste", ibgeCode: 35 } + * getStateByCep(20040020); // { code: "RJ", name: "Rio de Janeiro", regionCode: "SE", regionName: "Sudeste", ibgeCode: 33 } + * getStateByCep("69300-000")?.code; // "RR" + * getStateByCep("72800-000")?.code; // "GO" + * getStateByCep("00999-999"); // null + * getStateByCep("12345"); // null + * getStateByCep(-20040020); // null + * ``` + */ +export const getStateByCep = (value: string | number): State | null => { + if (!isLookupCode(value) || !isValidCep(value)) return null; + + const cep = Number(parseCep(value)); + + const state = DATA.find((entry) => + CEP_RANGES.some( + (range) => range.state === entry.code && cep >= range.start && cep <= range.end, + ), + ); + + return state ? { ...state } : null; +}; diff --git a/src/index.test.ts b/src/index.test.ts index deb4cf5b5..ab00644ff 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -191,6 +191,7 @@ const PUBLIC = [ "getNfeKeyInfo", "getPixKeyInfo", "getPixPayloadInfo", + "getStateByCep", "getStateByIbgeCode", "getStateCodeByName", "getStateNameByCode", diff --git a/src/index.ts b/src/index.ts index 3267ba776..b8cdef904 100644 --- a/src/index.ts +++ b/src/index.ts @@ -162,6 +162,7 @@ export { type PixPointOfInitiation, getPixPayloadInfo, } from "./get-pix-payload-info/get-pix-payload-info"; +export { getStateByCep } from "./get-state-by-cep/get-state-by-cep"; export { getStateByIbgeCode } from "./get-state-by-ibge-code/get-state-by-ibge-code"; export { getStateCodeByName } from "./get-state-code-by-name/get-state-code-by-name"; export { getStateNameByCode } from "./get-state-name-by-code/get-state-name-by-code"; From f4e7d5d9fa3115c13eba75f0a8a77fdf8f481086 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:44:15 -0300 Subject: [PATCH 2/3] refactor(get-state-by-cep): look the CEP up in the range table first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup walked the 27 states and re-scanned the 30 ranges for each one, up to 810 comparisons for every call. The question is which range holds the CEP, so the range table is the outer loop: at most 57 comparisons, and the shape reads like the sibling getStateByIbgeCode. Also say in the docs that a range is the block the state owns and not a promise that every CEP in it is in use, since 10000-000 to 10999-999 sits unused inside the range of São Paulo, and cover that block and the shape of the table (ascending, no overlap, one inner gap) with tests. --- docs/pt-br/utilities.md | 3 ++- docs/utilities.md | 3 ++- src/get-state-by-cep/constants.ts | 5 +++-- src/get-state-by-cep/get-state-by-cep.test.ts | 21 +++++++++++++++++++ src/get-state-by-cep/get-state-by-cep.ts | 12 +++++------ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 414ba1513..dd08b659b 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -1498,7 +1498,8 @@ Retorna o estado brasileiro ao qual um CEP pertence, a partir das faixas de CEP - Funciona offline: nenhuma API de CEP é chamada, então a resposta diz qual estado é dono da faixa, não se o CEP está em uso. - Aceita o que o `isValidCep` aceita: 8 dígitos, como string ou número, ignorando espaços, pontos e hifens. Um CEP que começa com `0` precisa ser uma string, e um número negativo ou fracionário é rejeitado. -- Amazonas, Distrito Federal e Goiás têm duas faixas cada, e nenhum estado é dono de `00000-000` a `00999-999` nem de `78900-000` a `78999-999`. +- Amazonas, Distrito Federal e Goiás têm duas faixas cada, e nenhuma faixa estadual cobre `00000-000` a `00999-999` nem `78900-000` a `78999-999`. +- A faixa é o bloco que pertence ao estado, não uma garantia de que todo CEP dentro dela está em uso: `10000-000` está sem uso dentro da faixa de São Paulo e ainda assim responde São Paulo. - Retorna `null` para um CEP inválido ou fora de todas as faixas. Exporta o tipo `State`. ```javascript diff --git a/docs/utilities.md b/docs/utilities.md index 8c157a4e1..7eb70d2a5 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -1498,7 +1498,8 @@ Get the Brazilian state a CEP belongs to, from the CEP ranges the Correios assig - It runs offline: no CEP API is called, so the answer says which state owns the range, not whether the CEP is in use. - Accepts what `isValidCep` accepts: 8 digits, as a string or a number, with spaces, dots and hyphens ignored. A CEP that starts with `0` has to be a string, and a negative or fractional number is rejected. -- Amazonas, Distrito Federal and Goiás have two ranges each, and no state owns `00000-000` to `00999-999` nor `78900-000` to `78999-999`. +- Amazonas, Distrito Federal and Goiás have two ranges each, and no state range covers `00000-000` to `00999-999` nor `78900-000` to `78999-999`. +- A range is the block the state owns, not a promise that every CEP in it is in use: `10000-000` sits unused inside the São Paulo range and still answers São Paulo. - Returns `null` for an invalid CEP or one outside every range. Exports the `State` type. ```javascript diff --git a/src/get-state-by-cep/constants.ts b/src/get-state-by-cep/constants.ts index 262676229..a88b89092 100644 --- a/src/get-state-by-cep/constants.ts +++ b/src/get-state-by-cep/constants.ts @@ -14,8 +14,9 @@ type CepRange = { * CEP ranges of each state ("Faixa de CEP" per UF), as answered by the Correios "Busca Faixa de * CEP" search when only the UF is given, in ascending order. Amazonas, Distrito Federal and Goiás * have two ranges each: Roraima sits inside the Amazonas block and the Goiás municipalities - * around Brasília sit inside the Distrito Federal block. No state owns `00000-000` to - * `00999-999` nor `78900-000` to `78999-999`. + * around Brasília sit inside the Distrito Federal block. No range covers `00000-000` to + * `00999-999` nor `78900-000` to `78999-999`. A range is the block the state owns, not a promise + * that every CEP in it is in use: `10000-000` to `10999-999` sits unused inside the São Paulo one. * * @see Official: https://buscacepinter.correios.com.br/app/faixa_cep_uf_localidade/index.php * @see Based on: https://gist.github.com/tamnil/792a6a66f6df9fc028041587cfca0c3d diff --git a/src/get-state-by-cep/get-state-by-cep.test.ts b/src/get-state-by-cep/get-state-by-cep.test.ts index 641465429..5c79f868f 100644 --- a/src/get-state-by-cep/get-state-by-cep.test.ts +++ b/src/get-state-by-cep/get-state-by-cep.test.ts @@ -4,6 +4,7 @@ import { type State } from "../_internals/constants/states"; import { anyGarbage, digits, digitsOfOtherLength } from "../_internals/test/arbitraries"; import { expectNeverThrows } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { CEP_RANGES } from "./constants"; import { getStateByCep } from "./get-state-by-cep"; const RANGE_BOUNDARIES: [string, string, string][] = [ @@ -81,6 +82,11 @@ describe("getStateByCep", () => { expect(getStateByCep("74000000")?.code).toBe("GO"); }); + it("should return the owner of the range for a CEP block no city uses", () => { + expect(getStateByCep("10000000")?.code).toBe("SP"); + expect(getStateByCep("10999999")?.code).toBe("SP"); + }); + it("should return null for the CEPs below the first range", () => { expect(getStateByCep("00000000")).toBeNull(); expect(getStateByCep("00999999")).toBeNull(); @@ -92,6 +98,21 @@ describe("getStateByCep", () => { expect(getStateByCep("78999999")).toBeNull(); }); + it("should hold a table in ascending order whose only inner gap is the one between Mato Grosso and Mato Grosso do Sul", () => { + const steps: string[] = []; + + for (const [index, range] of CEP_RANGES.slice(1).entries()) { + const previous = CEP_RANGES[index]; + + if (range.start !== previous.end + 1) steps.push(`${previous.end}|${range.start}`); + } + + expect(steps).toEqual(["78899999|79000000"]); + expect(CEP_RANGES).toHaveLength(30); + expect(CEP_RANGES[0].start).toBe(1_000_000); + expect(CEP_RANGES.at(-1)?.end).toBe(99_999_999); + }); + it("should return a fresh copy that does not mutate the underlying constant", () => { const state = getStateByCep("01310100"); if (state) Object.assign(state, { name: "X" }); diff --git a/src/get-state-by-cep/get-state-by-cep.ts b/src/get-state-by-cep/get-state-by-cep.ts index eb42f9f2c..9839b407f 100644 --- a/src/get-state-by-cep/get-state-by-cep.ts +++ b/src/get-state-by-cep/get-state-by-cep.ts @@ -17,8 +17,9 @@ export type { State } from "../_internals/constants/states"; * integer: a sign and a decimal point are not digits, so `-20040020` and `2004002.5` are rejected * instead of being read as a CEP. * - * Amazonas, Distrito Federal and Goiás have two ranges each. No state owns `00000-000` to - * `00999-999` nor `78900-000` to `78999-999`, so a CEP in one of them returns `null`. + * Amazonas, Distrito Federal and Goiás have two ranges each. No state range covers `00000-000` to + * `00999-999` nor `78900-000` to `78999-999`, so a CEP in one of them returns `null`. Inside a + * range the answer is the owner of the range even for a CEP no city uses, such as `10000-000`. * * @param {string|number} value - The CEP, with or without formatting. * @returns {State|null} The matching `State` object, or `null` when the value is not a valid CEP @@ -46,11 +47,8 @@ export const getStateByCep = (value: string | number): State | null => { const cep = Number(parseCep(value)); - const state = DATA.find((entry) => - CEP_RANGES.some( - (range) => range.state === entry.code && cep >= range.start && cep <= range.end, - ), - ); + const range = CEP_RANGES.find((entry) => cep >= entry.start && cep <= entry.end); + const state = range && DATA.find((entry) => entry.code === range.state); return state ? { ...state } : null; }; From 8047b208f8d56df09b005e0a23598f8b554c33b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 00:55:32 +0000 Subject: [PATCH 3/3] refactor(get-state-by-cep): share the CEP range lookup and rename the municipality table getStateByCep reads its range table through a new internal, _internals/find-cep-range, which validates and parses the CEP once and returns the range that holds it, so the lookup is written once. The IBGE municipality table moves from src/_internals/constants/cities.ts to municipalities.ts: it holds municipalities (with their codes and states), not a list of city names, and every util that reads it now imports it under that name. The generated file is unchanged apart from its path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- scripts/cities.ts | 4 +- scripts/data-summary.ts | 2 +- scripts/data.ts | 2 +- .../{cities.ts => municipalities.ts} | 0 .../find-cep-range/find-cep-range.test.ts | 53 +++++++++++++++++++ .../find-cep-range/find-cep-range.ts | 41 ++++++++++++++ src/get-cities/get-cities.test.ts | 2 +- src/get-cities/get-cities.ts | 2 +- .../get-municipalities.test.ts | 2 +- src/get-municipalities/get-municipalities.ts | 4 +- .../get-municipality-by-code.test.ts | 2 +- .../get-municipality-by-code.ts | 4 +- src/get-municipality/get-municipality.ts | 2 +- src/get-state-by-cep/get-state-by-cep.ts | 10 +--- src/index.ts | 2 +- 15 files changed, 110 insertions(+), 22 deletions(-) rename src/_internals/constants/{cities.ts => municipalities.ts} (100%) create mode 100644 src/_internals/find-cep-range/find-cep-range.test.ts create mode 100644 src/_internals/find-cep-range/find-cep-range.ts diff --git a/scripts/cities.ts b/scripts/cities.ts index 12d6ec716..2c09fbd57 100644 --- a/scripts/cities.ts +++ b/scripts/cities.ts @@ -105,8 +105,8 @@ const main = async (): Promise => { } await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/cities.ts"), - `import type { StateCode } from "./states"; + resolve(scriptsDir, "..", "./src/_internals/constants/municipalities.ts"), + `import { type StateCode } from "./states"; /** * Brazilian municipalities by state, published by the IBGE. \`DATA\` holds, for each state, a diff --git a/scripts/data-summary.ts b/scripts/data-summary.ts index 3dd872471..b23514099 100644 --- a/scripts/data-summary.ts +++ b/scripts/data-summary.ts @@ -19,8 +19,8 @@ const DATASETS: Record = { "src/_internals/constants/banks.ts": "Banks (Banco Central, STR participants)", "src/_internals/constants/cbo.ts": "CBO 2002 occupations (Ministério do Trabalho e Emprego)", "src/_internals/constants/cfop.ts": "CFOP codes (CONFAZ, Convênio SINIEF s/nº 1970)", - "src/_internals/constants/cities.ts": "Municipalities (IBGE)", "src/_internals/constants/cnae.ts": "CNAE subclasses (IBGE/CONCLA)", + "src/_internals/constants/municipalities.ts": "Municipalities (IBGE)", "src/_internals/constants/states.ts": "States (IBGE)", "src/is-valid-legal-nature/constants.ts": "Legal natures (IBGE/CONCLA)", "src/is-valid-ncm/constants.ts": "NCM codes (Siscomex)", diff --git a/scripts/data.ts b/scripts/data.ts index 850c569d0..266eedbab 100644 --- a/scripts/data.ts +++ b/scripts/data.ts @@ -34,8 +34,8 @@ const generatedFiles = [ "./src/_internals/constants/banks.ts", "./src/_internals/constants/cbo.ts", "./src/_internals/constants/cfop.ts", - "./src/_internals/constants/cities.ts", "./src/_internals/constants/cnae.ts", + "./src/_internals/constants/municipalities.ts", "./src/_internals/constants/states.ts", "./src/is-valid-legal-nature/constants.ts", "./src/is-valid-ncm/constants.ts", diff --git a/src/_internals/constants/cities.ts b/src/_internals/constants/municipalities.ts similarity index 100% rename from src/_internals/constants/cities.ts rename to src/_internals/constants/municipalities.ts diff --git a/src/_internals/find-cep-range/find-cep-range.test.ts b/src/_internals/find-cep-range/find-cep-range.test.ts new file mode 100644 index 000000000..94a2e544e --- /dev/null +++ b/src/_internals/find-cep-range/find-cep-range.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "../test/runtime"; +import { findCepRange } from "./find-cep-range"; + +const RANGES = [ + { start: 1_000_000, end: 19_999_999, label: "SP" }, + { start: 20_000_000, end: 28_999_999, label: "RJ" }, +]; + +describe("findCepRange", () => { + test("should return the range that contains a formatted CEP", () => { + expect(findCepRange("01310-100", RANGES)).toEqual(RANGES[0]); + }); + + test("should return the range that contains a CEP given as a number", () => { + expect(findCepRange(20_040_020, RANGES)).toEqual(RANGES[1]); + }); + + test("should return the range that owns the first and the last CEP of a range", () => { + expect(findCepRange("01000-000", RANGES)).toEqual(RANGES[0]); + expect(findCepRange("19999-999", RANGES)).toEqual(RANGES[0]); + }); + + test("should return null for a CEP outside every range", () => { + expect(findCepRange("29000-000", RANGES)).toBeNull(); + }); + + test("should return null for an empty table", () => { + expect(findCepRange("01310-100", [])).toBeNull(); + }); + + test("should return null for an invalid CEP", () => { + expect(findCepRange("12345", RANGES)).toBeNull(); + expect(findCepRange("abc01310100", RANGES)).toBeNull(); + expect(findCepRange(-20_040_020, RANGES)).toBeNull(); + expect(findCepRange(2_004_002.5, RANGES)).toBeNull(); + }); + + test("should return null for a value that is not a string or a number", () => { + // @ts-expect-error: intentionally invalid input + expect(findCepRange(null, RANGES)).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(findCepRange(undefined, RANGES)).toBeNull(); + }); + + test("should return the first match when ranges overlap", () => { + const overlapping = [ + { start: 1_000_000, end: 2_000_000, label: "first" }, + { start: 1_500_000, end: 2_500_000, label: "second" }, + ]; + + expect(findCepRange("01500-000", overlapping)).toEqual(overlapping[0]); + }); +}); diff --git a/src/_internals/find-cep-range/find-cep-range.ts b/src/_internals/find-cep-range/find-cep-range.ts new file mode 100644 index 000000000..79ba72354 --- /dev/null +++ b/src/_internals/find-cep-range/find-cep-range.ts @@ -0,0 +1,41 @@ +import { isValidCep } from "../../is-valid-cep/is-valid-cep"; +import { parseCep } from "../../parse-cep/parse-cep"; +import { isLookupCode } from "../is-lookup-code/is-lookup-code"; + +/** One entry of a table `findCepRange` searches: any range with a first and a last CEP. */ +type CepRange = { + /** First CEP of the range, as a number. */ + readonly start: number; + /** Last CEP of the range, as a number. */ + readonly end: number; +}; + +/** + * Validates `value` as `isValidCep` does and finds the entry of `ranges` whose `start`/`end` + * bounds contain it, so the validation and the parsing of `getStateByCep` happen only once. + * + * `ranges` is searched in order and the first match wins, so a caller whose ranges may overlap + * controls the answer through their order; every range table this package ships is disjoint, + * so the order never matters in practice. + * + * @param {string|number} value - The CEP, with or without formatting. + * @param {readonly CepRange[]} ranges - The table to search, each entry at least a `CepRange`. + * @returns {T|null} The matching entry, or `null` when `value` is not a valid CEP or falls + * outside every range. + * + * @example + * ```typescript + * findCepRange("01310-100", [{ start: 1_000_000, end: 19_999_999 }]); // { start: 1_000_000, end: 19_999_999 } + * findCepRange("00999-999", [{ start: 1_000_000, end: 19_999_999 }]); // null + * ``` + */ +export const findCepRange = ( + value: string | number, + ranges: readonly T[], +): T | null => { + if (!isLookupCode(value) || !isValidCep(value)) return null; + + const cep = Number(parseCep(value)); + + return ranges.find((entry) => cep >= entry.start && cep <= entry.end) ?? null; +}; diff --git a/src/get-cities/get-cities.test.ts b/src/get-cities/get-cities.test.ts index 1b3aa49d5..b2f238557 100644 --- a/src/get-cities/get-cities.test.ts +++ b/src/get-cities/get-cities.test.ts @@ -1,6 +1,6 @@ import * as fc from "fast-check"; -import { DATA } from "../_internals/constants/cities"; +import { DATA } from "../_internals/constants/municipalities"; import { type StateCode } from "../_internals/constants/states"; import { anyGarbage, stateCodes } from "../_internals/test/arbitraries"; import { expectNeverThrows } from "../_internals/test/properties"; diff --git a/src/get-cities/get-cities.ts b/src/get-cities/get-cities.ts index eb71c02a7..aec8398d2 100644 --- a/src/get-cities/get-cities.ts +++ b/src/get-cities/get-cities.ts @@ -1,4 +1,4 @@ -import { DATA as CITIES_DATA } from "../_internals/constants/cities"; +import { DATA as CITIES_DATA } from "../_internals/constants/municipalities"; import { type StateCode } from "../_internals/constants/states"; export type { StateCode } from "../_internals/constants/states"; diff --git a/src/get-municipalities/get-municipalities.test.ts b/src/get-municipalities/get-municipalities.test.ts index 3601c4521..37a778a66 100644 --- a/src/get-municipalities/get-municipalities.test.ts +++ b/src/get-municipalities/get-municipalities.test.ts @@ -1,6 +1,6 @@ import * as fc from "fast-check"; -import { DATA, type Municipality } from "../_internals/constants/cities"; +import { DATA, type Municipality } from "../_internals/constants/municipalities"; import { type StateCode } from "../_internals/constants/states"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { getCities } from "../get-cities/get-cities"; diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts index f77f25a94..27a6a76bd 100644 --- a/src/get-municipalities/get-municipalities.ts +++ b/src/get-municipalities/get-municipalities.ts @@ -1,7 +1,7 @@ -import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/cities"; +import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/municipalities"; import { DATA, type StateCode } from "../_internals/constants/states"; -export type { Municipality } from "../_internals/constants/cities"; +export type { Municipality } from "../_internals/constants/municipalities"; export type { StateCode } from "../_internals/constants/states"; const buildMunicipalities = (stateCode: StateCode): Municipality[] => diff --git a/src/get-municipality-by-code/get-municipality-by-code.test.ts b/src/get-municipality-by-code/get-municipality-by-code.test.ts index f0853a185..a60f7a2ce 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.test.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.test.ts @@ -1,6 +1,6 @@ import * as fc from "fast-check"; -import { type Municipality } from "../_internals/constants/cities"; +import { type Municipality } from "../_internals/constants/municipalities"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { getMunicipalities } from "../get-municipalities/get-municipalities"; import { getMunicipalityByCode } from "./get-municipality-by-code"; diff --git a/src/get-municipality-by-code/get-municipality-by-code.ts b/src/get-municipality-by-code/get-municipality-by-code.ts index a8569b44a..e0f16bddf 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.ts @@ -1,9 +1,9 @@ -import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/cities"; +import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/municipalities"; import { DATA } from "../_internals/constants/states"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -export type { Municipality } from "../_internals/constants/cities"; +export type { Municipality } from "../_internals/constants/municipalities"; /** * Looks up a Brazilian municipality by its 7 digit IBGE code, published by the IBGE. diff --git a/src/get-municipality/get-municipality.ts b/src/get-municipality/get-municipality.ts index 65e561685..64fcc9323 100644 --- a/src/get-municipality/get-municipality.ts +++ b/src/get-municipality/get-municipality.ts @@ -1,4 +1,4 @@ -import { DATA as CITIES_DATA } from "../_internals/constants/cities"; +import { DATA as CITIES_DATA } from "../_internals/constants/municipalities"; import { type StateCode } from "../_internals/constants/states"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { isNullish } from "../_internals/is-nullish/is-nullish"; diff --git a/src/get-state-by-cep/get-state-by-cep.ts b/src/get-state-by-cep/get-state-by-cep.ts index 9839b407f..ceef8b407 100644 --- a/src/get-state-by-cep/get-state-by-cep.ts +++ b/src/get-state-by-cep/get-state-by-cep.ts @@ -1,7 +1,5 @@ import { DATA, type State } from "../_internals/constants/states"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { isValidCep } from "../is-valid-cep/is-valid-cep"; -import { parseCep } from "../parse-cep/parse-cep"; +import { findCepRange } from "../_internals/find-cep-range/find-cep-range"; import { CEP_RANGES } from "./constants"; export type { State } from "../_internals/constants/states"; @@ -43,11 +41,7 @@ export type { State } from "../_internals/constants/states"; * ``` */ export const getStateByCep = (value: string | number): State | null => { - if (!isLookupCode(value) || !isValidCep(value)) return null; - - const cep = Number(parseCep(value)); - - const range = CEP_RANGES.find((entry) => cep >= entry.start && cep <= entry.end); + const range = findCepRange(value, CEP_RANGES); const state = range && DATA.find((entry) => entry.code === range.state); return state ? { ...state } : null; diff --git a/src/index.ts b/src/index.ts index b8cdef904..d7d285c3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import { isValidIe } from "./is-valid-ie/is-valid-ie"; import { isValidPis } from "./is-valid-pis/is-valid-pis"; export type { Bank } from "./_internals/constants/banks"; -export type { Municipality } from "./_internals/constants/cities"; +export type { Municipality } from "./_internals/constants/municipalities"; export type { State, StateCode, StateName } from "./_internals/constants/states"; export type { NumberToWordsGender } from "./_internals/number-to-words/number-to-words"; export { addBusinessDays } from "./add-business-days/add-business-days";