Building a Mini-Program App with TypeScript: Lessons from an M-Pesa Journey
I was building a mini-program app; a lightweight application within Safaricom M-Pesa that doesn’t require users to download separate apps on their device while leveraging M-Pesa APIs, M-Pesa reach, and M-Pesa’s payment infrastructure.
Inspiration
You’d think with the popularity of Safaricom M-Pesa and Alipay, there’d be ample documentation on building mini-program apps with TypeScript; but I found none. This blog shares what I’ve learned so you can build your next mini-program app with TypeScript without the same struggle. While working through the official Todo app template, I discovered a type bug, thanks to TypeScript’s sharp eyes. What I couldn’t find in docs, I found in practice, and I’m laying it out here to save you the headache.
What We Will Cover
- Initialize the Todo app with the official Todo app template.
- Configure the app to use TypeScript instead of JavaScript.
- Refactor the Todo app to TypeScript.
- Set up a build step before deploying the app to the M-Pesa mini-program platform.
Here’s the clincher: while converting the Todo app’s official JavaScript template to TypeScript, I uncovered a bug that slipped by in plain JavaScript. TypeScript didn’t just prove its worth; it delivered a win right out of the gate.
Why TypeScript?
Before diving into the setup, let’s talk about why I made the switch in the first place. TypeScript isn’t just JavaScript with types; it’s a productivity booster. Here’s what sold me:
- Static Typing: Prevents type-related runtime errors early in development.
- Better Code Completion & Refactoring: Editor support is significantly improved.
- Scalability: As a project grows, TypeScript helps maintain structure and readability.
- Bug Detection: Many JavaScript pitfalls (like undefined or null errors) are caught during development.
These benefits made TypeScript a no-brainer for my mini-app. Now, onto the setup.
M-Pesa mini-programs primarily provide JavaScript templates, so transitioning to TypeScript requires a few extra steps.
Here’s how I did it:
Initialize the App
Start by initializing the app using the Todo template in Mini Program Studio. Follow the prompts, and you should have a working Todo app.
Install TypeScript
In Mini Program Studio, install TypeScript as a dev dependency.
Create Configuration Files
In the root directory, create two files: mini.project.json
and tsconfig.json
.
Configure TypeScript
{
"compilerOptions": {
"target": "es6",
"strict": true,
"noEmit": false,
"outDir": "./dist",
"rootDir": ".",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"skipLibCheck": true,
"types": ["@mini-types/alipay"],
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["app.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist", "types"]
}
I’ve included the minimum rules needed to start, but this file can be extended based on your team’s or project’s needs.
Enable TypeScript for the App
{
"format": 2,
"compileOptions": {
"typescript": true
}
}
- At this point, the app will break because it expects TypeScript files instead of JavaScript files.
Refactor the Todo App to TypeScript
There’s no strict preference for folder structure, but the new structure we’ll use achieves two goals:
- Groups the code that will be transpiled back to JavaScript in a source folder (more on this later).
- Converts all
.js
files to.ts
files to fix the error in Mini Program Studio.
Steps to Follow
- Rename
.js
files to.ts
and update imports to reflect the TypeScript format. - Update
app.js
toapp.ts
and add types where needed.
This is what my new folder structure looks like:
.
├── README.md
├── app.acss
├── app.json
├── app.ts
├── assets
│ └── logo.png
├── mini.project.json
├── package-lock.json
├── package.json
├── scripts
│ └── build.js
├── src
│ ├── components
│ └── pages
├── tsconfig.json
└── types
├── index.ts
├── todos.d.ts
└── user.d.ts
I’ve also added a types
folder in the root directory and updated the codebase where types are required.
export interface Todo {
text: string;
completed: boolean;
}
export interface UserInfo {
response: string;
avatar: string;
code: string;
nickName: string;
msg: string;
subMsg: string;
subCode: string;
}
export * from "./user";
export * from "./todos";
import { Todo, UserInfo } from "./types";
// Get the global app instance
interface IData {
todos: Todo[];
}
const data: IData = {
todos: [
{ text: "Learning Javascript", completed: true },
{ text: "Learning ES2016", completed: true },
{ text: "Learning miniProgram", completed: false },
]
};
let userInfo: UserInfo | undefined;
App({
data,
userInfo,
getUserInfo() {
return new Promise((resolve, reject) => {
if (this.userInfo) resolve(this.userInfo);
my.getAuthCode({
scopes: ["auth_user"],
success: authcode => {
console.info(authcode);
my.getOpenUserInfo({
success: res => {
this.userInfo = res;
resolve(this.userInfo);
},
fail: () => {
reject({});
},
});
},
fail: () => {
reject({});
},
});
});
},
});
import { Todo } from "../../../types/todos";
interface IData {
todos: Todo[];
}
interface IGlobalData {
data: {
todos: Todo[];
};
}
// Get the global app instance
const app = getApp<IGlobalData>();
// Page state
const data: IData = {
todos: []
};
Page({
// Declare page data
data,
// Listening lifecycle callback onShow
onShow() {
// Set global data to current page data
this.setData({ todos: app.data.todos });
},
// Event handler
onTodoChanged(e: CustomEvent) {
// Modify global data
const checkedTodos = e.detail.value;
app.data.todos = app.data.todos.map(todo => ({
...todo,
completed: checkedTodos.indexOf(todo.text) > -1,
}));
this.setData({ todos: app.data.todos });
},
addTodo() {
// Make a page jump
my.navigateTo({ url: "../add-todo/add-todo" });
},
});
Component({
props: {
text: "Button",
onClickMe: () => {},
},
methods: {
onClickMe() {
this.props.onClickMe();
},
},
});
import { Todo } from "../../../types";
interface IGlobalData {
data: {
todos: Todo[];
};
}
const app = getApp<IGlobalData>();
Page({
data: {
inputValue: "",
},
onBlur(e: CustomEvent) {
this.setData({
inputValue: e.detail.value,
});
},
add() {
app.data.todos = app.data.todos.concat([
{
text: this.data.inputValue,
compeleted: false,
},
]);
my.navigateBack();
},
});
At this point, TypeScript will error: Type '{ text: string; compeleted: false; }' is not assignable to type 'Todo'.
The template misspelled “completed”. This is one of many reasons why we love TypeScript. Fix the typo, and the app should run smoothly in TypeScript.
- Set up a Build Step Before Deploying the App to the M-Pesa Mini-Program Platform
The M-Pesa platform expects a JavaScript build to be deployed, and I wish this was a configurable step that could run on the M-Pesa platform so I could avoid doing it locally and then uploading the transpiled code to the M-Pesa mini-program platform.
Make the following changes:
- Add the build command to run the build script.
"scripts": {
"build": "node scripts/build.js"
},
Don’t worry too much about the build script unless you need to make changes; you can get away with Googling. Briefly, the script uses tsc
to transpile the code back to JavaScript.
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const config = {
srcRoot: path.resolve("."),
destRoot: path.resolve("./dist"),
directories: ["src", "assets"],
files: ["app.json", "package.json", "app.acss"],
transformFiles: ["mini.project.json"],
excludeDirs: ["types"],
};
function copyNonTsFiles(src, dest, excludeDirs = []) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
const relativePath = path.relative(config.srcRoot, srcPath);
if (
excludeDirs.some(
exclude => relativePath === exclude || relativePath.startsWith(exclude + path.sep)
)
) {
console.log(`Skipping excluded directory: ${relativePath}`);
continue;
}
if (entry.isDirectory()) {
copyNonTsFiles(srcPath, destPath, excludeDirs);
} else if (!srcPath.endsWith(".ts")) {
fs.copyFileSync(srcPath, destPath);
}
}
}
function transformMiniProjectJson(srcPath, destPath) {
console.log("Transforming mini.project.json...");
try {
const content = JSON.parse(fs.readFileSync(srcPath, "utf8"));
const transformed = { format: content.format };
fs.writeFileSync(destPath, JSON.stringify(transformed, null, 2), "utf8");
} catch (error) {
console.error(`Failed to transform mini.project.json: ${error.message}`);
process.exit(1);
}
}
console.log("Cleaning dist folder...");
try {
execSync("npx rimraf dist", { stdio: "inherit" });
} catch (error) {
console.error(`Clean failed: ${error.message}`);
process.exit(1);
}
console.log("Transpiling TypeScript...");
try {
execSync("npx tsc", { stdio: "inherit" });
} catch (error) {
console.error(`Transpile failed: ${error.message}`);
process.exit(1);
}
console.log("Copying files...");
for (const dir of config.directories) {
const srcPath = path.join(config.srcRoot, dir);
const destPath = path.join(config.destRoot, dir);
if (fs.existsSync(srcPath)) {
copyNonTsFiles(srcPath, destPath, config.excludeDirs);
} else {
console.warn(`Warning: '${dir}' does not exist and will be skipped.`);
}
}
for (const file of config.files) {
const srcPath = path.join(config.srcRoot, file);
const destPath = path.join(config.destRoot, file);
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, destPath);
} else {
console.warn(`Warning: '${file}' does not exist and will be skipped.`);
}
}
for (const file of config.transformFiles) {
const srcPath = path.join(config.srcRoot, file);
const destPath = path.join(config.destRoot, file);
if (fs.existsSync(srcPath)) {
transformMiniProjectJson(srcPath, destPath);
} else {
console.error(`Error: '${file}' is missing`);
process.exit(1);
}
}
console.log("Build complete!");
Voila!
You should have a working app that takes advantage of TypeScript and outputs code that can be uploaded to the M-Pesa mini-program platform for the 34 million M-Pesa users.
If you’re on the fence about TypeScript, I highly recommend giving it a shot, especially for structured applications like mini-apps. The upfront effort pays off tenfold in reduced debugging and improved code quality.