DEV Community

Cover image for I built kenya-utils so you can stop copy-pasting the same regexes between projects
Collins Mbathi
Collins Mbathi

Posted on

I built kenya-utils so you can stop copy-pasting the same regexes between projects

Every Kenyan dev I know has the same folder on their laptop: a half-finished utils/ directory with validatePhone.ts, kraPin.ts, counties.json, and a M-PESA SMS parser that mostly works. We've all written it. We've all copied it into the next project. Then the regex breaks because Safaricom released a new prefix, and we patch it in one project and forget the other two.

I got tired of doing this. So I packaged it.

kenya-utils is a free, MIT-licensed TypeScript library with the Kenya-specific helpers you actually use: phones, KRA PINs, national IDs (including Maisha cards), the 47 counties, M-PESA, and shilling formatting. Zero dependencies. Works everywhere modern JavaScript runs.

npm install kenya-utils
Enter fullscreen mode Exit fullscreen mode

This article walks through what's inside, where each piece is useful, and the design choices that should make it boring to adopt. Whether you're a student building your first form or a senior engineer shipping fintech at scale, there's something here that'll save you an afternoon.

The 30-second tour

import { parsePhone, isValidKraPin, findCounty, formatKes } from "kenya-utils";

parsePhone("0712345678");
// { e164: "+254712345678", network: "Safaricom", ... }

isValidKraPin("A123456789B"); // true

findCounty("nairobi");
// { code: 47, name: "Nairobi", capital: "Nairobi", ... }

formatKes(1234567); // "Ksh 1,234,567.00"
Enter fullscreen mode Exit fullscreen mode

That's it. Four imports, four common problems solved.

You can also import only the module you need, so unused code doesn't ship to your bundle:

import { parsePhone } from "kenya-utils/phone";
import { findCounty } from "kenya-utils/counties";
Enter fullscreen mode Exit fullscreen mode

Each module is a separate entry. If you only need county data, your final bundle won't carry the M-PESA parser.

Phones — the one everyone needs

Every signup form in Kenya needs phone validation. Every one of them gets it slightly wrong.

import { parsePhone, isValidKePhone, formatPhone, detectNetwork } from "kenya-utils/phone";

parsePhone("+254 712 345 678");
// {
//   e164: "+254712345678",
//   national: "0712345678",
//   international: "+254 712 345 678",
//   subscriber: "712345678",
//   network: "Safaricom",
//   countryCode: "254"
// }

isValidKePhone("0712345678"); // true
isValidKePhone("0202345678"); // false (landline)

formatPhone("0712345678");                  // "+254712345678"
formatPhone("+254712345678", "national");   // "0712345678"

detectNetwork("0730123456"); // "Airtel"
detectNetwork("0747123456"); // "Faiba"
Enter fullscreen mode Exit fullscreen mode

Things that took me longer than I'd like to admit to get right:

  • Accepting every format users actually type. +254..., 254..., 0..., bare 9-digit subscriber numbers. Whitespace, hyphens, dots, and parens are all stripped.
  • Returning null on garbage, not throwing. You can call it on any input without try/catch ceremony.
  • Network detection by prefix. Useful for UX hints ("we'll send a Safaricom STK push"). But — and this matters — Kenya has number portability. A 0722 number could now be on Airtel. Treat detection as a hint, not a guarantee. The README is honest about this.

KRA PINs — for any system that touches tax

If you're building anything for businesses, payroll, invoicing, or e-citizen integrations, you'll need KRA PIN validation eventually.

import { isValidKraPin, parseKraPin, formatKraPin } from "kenya-utils/kra-pin";

isValidKraPin("A123456789B"); // true (Individual)
isValidKraPin("P987654321Z"); // true (Non-Individual)
isValidKraPin("a 123-456-789 b"); // true — case and whitespace tolerant

parseKraPin("A123456789B");
// {
//   pin: "A123456789B",
//   type: "Individual",
//   prefix: "A",
//   sequence: "123456789",
//   checkChar: "B"
// }
Enter fullscreen mode Exit fullscreen mode

A prefix = individuals. P = everything else (companies, partnerships, trusts, societies). The format is [A|P] + 9 digits + 1 letter. The lenient parsing (a 123-456-789 b works) matters because users will paste PINs from PDFs, emails, and screenshots, and they will have stray whitespace every time.

National IDs and Maisha cards

The old IDs are 7 digits. The newer ones are 8. The Maisha Namba uses the same numeric format, so the validator handles all three.

import {
  isValidKeId,
  parseKeId,
  parseMaishaCard,
  isMaishaCardExpired,
} from "kenya-utils/national-id";

isValidKeId("12345678"); // true
parseKeId("1234567");    // { id: "1234567", digits: 7, isLegacy: true }

parseMaishaCard({ id: "12345678", expiry: "2030-01-01" });
// { id: "12345678", expiryDate: ..., isExpired: false, daysUntilExpiry: 1234 }

isMaishaCardExpired("2020-01-01"); // true
Enter fullscreen mode Exit fullscreen mode

The Maisha Card is different from the Maisha Namba — the namba is the number, the card is the physical credential with an expiry date. Most apps care about both, so both are covered.

Counties — all 47, properly structured

Building a county dropdown shouldn't require maintaining your own JSON. It always does.

import {
  counties,
  findCounty,
  countiesByRegion,
  findCountyBySubCounty,
} from "kenya-utils/counties";

counties.length; // 47

findCounty(47);             // by code
findCounty("Mombasa");      // by name
findCounty("tana-river");   // by slug

countiesByRegion("Coast");
// [Mombasa, Kwale, Kilifi, Tana River, Lamu, Taita Taveta]

findCountyBySubCounty("Westlands")?.name; // "Nairobi"
Enter fullscreen mode Exit fullscreen mode

Each county object looks like:

{
  code: 47,
  name: "Nairobi",
  capital: "Nairobi",
  slug: "nairobi",
  region: "Nairobi",
  subCounties: ["Westlands", "Dagoretti North", ...]
}
Enter fullscreen mode Exit fullscreen mode

The slug field is what you want for URLs. The code is what you want when integrating with government APIs that use numeric codes. The subCounties list is what you want when a user picks a county and you need to narrow them to a constituency.

If you spot a sub-county that's missing or wrong, please PR it. Boundaries shift and I'd rather the data be correct than my pride preserved.

Currency — formatting and parsing shillings

import { formatKes, parseKes, toKesWords } from "kenya-utils/currency";

formatKes(1234567);                  // "Ksh 1,234,567.00"
formatKes(1234, { decimals: 0 });    // "Ksh 1,234"
formatKes(1234, { symbol: "KES" });  // "KES 1,234.00"

parseKes("Ksh 1,234,567.50");        // 1234567.5
parseKes("500 shillings");           // 500

toKesWords(1234.50);
// "one thousand two hundred thirty four shillings and fifty cents"
Enter fullscreen mode Exit fullscreen mode

toKesWords is the one most people don't realize they need until they're building a cheque-printing module or a formal receipt. Locale-aware number-to-words is harder than it looks. This handles it for KES, including the "and X cents" tail.

M-PESA — paybill validation and SMS parsing

Two practical things: validate paybill/till numbers before submission, and parse the SMS confirmations Safaricom sends.

import { isValidPaybill, isValidTillNumber, parseMpesaSms } from "kenya-utils/mpesa";

isValidPaybill(123456); // true

const r = parseMpesaSms(
  "QHJ7K8L9M0 Confirmed. Ksh1,000.00 sent to JOHN DOE 0712345678 on 9/5/26 at 10:30 AM. New M-PESA balance is Ksh5,000.00. Transaction cost, Ksh23.00.",
);
// {
//   transactionId: "QHJ7K8L9M0",
//   type: "sent",
//   amount: 1000,
//   party: "JOHN DOE",
//   partyPhone: "0712345678",
//   balance: 5000,
//   transactionCost: 23,
//   ...
// }
Enter fullscreen mode Exit fullscreen mode

The parser handles the common formats: sent, received, paybill, buygoods, withdraw, airtime. Safaricom occasionally tweaks the wording, so it's regex-driven and forgiving. If a real SMS format breaks the parser, open an issue with a redacted sample — that's exactly the kind of contribution that makes the library better for everyone.

This is useful for:

  • Personal finance apps that read SMS via the Android API.
  • Reconciliation tools that need to match incoming payments to invoices.
  • Bookkeeping bots that turn M-PESA receipts into ledger entries.
  • Hackathon projects where you don't want to write a parser at 2 AM.

What's intentionally not here yet

Being honest matters in a launch post:

  • Wards. There are 1,450+ wards across 290 constituencies. I want them sourced properly rather than typed from memory. Coming in v1.2.
  • VAT and withholding tax helpers. Useful but I want to verify rates against a current KRA source. v1.3.
  • More M-PESA edge cases — Pochi la Biashara, M-Shwari, KCB-MPESA. The big formats are covered; the long tail is incoming.

Bundle and runtime

  • Zero runtime dependencies.
  • ESM + CJS builds, both ship with TypeScript types.
  • sideEffects: false for tree-shaking.
  • Runs in Node 20+, modern browsers, Next.js (server and client components), Cloudflare Workers, Deno, and Bun.

If you import one module, you get that module's bytes. If you import nothing, you ship nothing. This sounds obvious but a surprising number of "utility" libraries don't bother.

FAQ

Will the network detection work for ported numbers?
It returns the original prefix-owner. Number portability means ~5% of detections will be technically wrong. Use it for UX hints, not for billing logic.

Does it work offline / on the edge?
Yes. No network calls, no DOM, no Node-only APIs. Cloudflare Workers, Vercel Edge, Deno Deploy, and React Native all work.

Can I use it without TypeScript?
Yes. Plain JS works the same. Types are bundled for editors that care.

What about KSH vs Ksh vs KES?
formatKes defaults to "Ksh" but accepts a symbol option for "KES", "KSh", or whatever your finance team prefers.

Is the M-PESA parser official?
No. It's a community-maintained regex against the SMS format Safaricom uses. The Daraja API is the right move for real integrations. The parser is for cases where you're reading the SMS after the fact (personal finance, reconciliation, etc.).

Why not split each module into its own package?
Because every Kenyan project ends up needing at least three of these, and seven kenya-* dependencies is worse than one with sub-imports. Tree-shaking handles the bundle-size concern.

Try it

npm install kenya-utils
# or
yarn add kenya-utils
# or
pnpm add kenya-utils
Enter fullscreen mode Exit fullscreen mode

GitHub logo Collins87mbathi / kenya-utils

Kenya utilities for JavaScript & TypeScript — phone numbers, KRA PIN, national IDs (Maisha cards), the 47 counties, M-PESA, and shilling formatting. Zero deps, fully typed.

kenya-utils

Helpers for Kenya-specific data in JavaScript and TypeScript — phone numbers, KRA PINs, national IDs (including Maisha cards), the 47 counties, M-PESA, and shilling formatting.

I built this because I kept copying the same regexes between projects. If you've been doing the same, this is for you.

CI npm bundle size license

Install

npm install kenya-utils
yarn add kenya-utils
pnpm add kenya-utils
Enter fullscreen mode Exit fullscreen mode

Works in Node 20+, browsers, Next.js (server and client), Cloudflare Workers, Deno, Bun. No dependencies.

A quick taste

import { parsePhone, isValidKraPin, findCounty, formatKes } from "kenya-utils";

parsePhone("0712345678");
// { e164: "+254712345678", network: "Safaricom", ... }

isValidKraPin("A123456789B"); // true

findCounty("nairobi");
// { code: 47, name: "Nairobi", capital: "Nairobi", ... }

formatKes(1234567); // "Ksh 1,234,567.00"
Enter fullscreen mode Exit fullscreen mode

You can also import only the module you need so unused code doesn't ship with your bundle:

import
Enter fullscreen mode Exit fullscreen mode

If you ship a Kenyan-market app this month, swap one of these in and see how it feels. If you find a phone prefix that's missing, an M-PESA SMS format that breaks the parser, or a sub-county that's wrong, open an issue or a PR. The library gets better with every report.

And if it saves you the 20 minutes you usually spend rewriting validatePhone.ts from scratch, that's the whole point.

Top comments (0)