dsteem Modernization Plan -Phase 3
Day 3: I bumped TypeScript from 3.1.6 (October 2018) to 5.6.3 (current), turned on
strict: true, switched tomoduleResolution: Bundler, and enabledesModuleInterop. Every breaking change between those two TS versions had to be reconciled, and the source code surfaced about a dozen real type errors that the original loose config was happily hiding. Here's the modernizedtsconfig.json, the import shape that broke, and what I deferred to Phase 5 (and why).

- Day 1 — Phase 0: dsteem Modernization Plan
- Day 2 — Phase 1: CI Plumbing on GitHub Actions
- Day 3 — Phase 2: Retiring tslint, Adopting ESLint 9
- Today — Phase 3: TypeScript 3.1 → 5.6 + Strict Mode (you are here)
- Tomorrow — Phase 4: The
@noblecrypto swap (highest risk) - Day 6 — Phase 5: Build modernization with
tsup+ dual ESM/CJS - Day 7 — Phase 6: Mocha 11, c8 coverage, Playwright browser tests
- Day 8 — Phase 7: typedoc 0.13 → 0.28
- Next week — Phase 8: Cleanup, README refresh, and the
v0.12.0release
How Old Was TypeScript 3.1?
For context, here's what released in the same week as TypeScript 3.1.6 (Nov 2018):
- iOS 12.1 (added 70 new emoji 🫠)
- macOS Mojave 10.14.1
- The original Red Dead Redemption 2
We've had four major TypeScript versions since then. Every one of them shipped behavioral changes — some opt-in, some at the language level. The most impactful for this migration:
| Version | Released | What broke for us |
|---|---|---|
| 3.7 | Nov 2019 | class X { y!: T } definite-assignment, optional chaining |
| 4.0 | Aug 2020 | Variadic tuple types changed inference |
| 4.4 | Aug 2021 | useUnknownInCatchVariables defaults shifted catch shape |
| 4.5 | Nov 2021 | Awaited<T> widened Promise narrowing |
| 4.8 | Aug 2022 | unknown in catch variables made default |
| 5.0 | Mar 2023 | Decorators landed (stage 3 version), const type params |
| 5.1 | Jun 2023 | Response.json() returns Promise<unknown> (not any) |
| 5.6 | Sep 2024 | Iterator and IteratorObject standardized |
The two that bit us specifically: 5.1's Response.json() and 4.8's catch widening.
The New tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022", "DOM"],
"outDir": "lib",
"declaration": true,
"sourceMap": true,
"inlineSources": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noImplicitThis": true,
"strictNullChecks": true,
"noImplicitAny": true,
"types": ["node", "verror"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "lib", "dist", "test"]
}
A few choices that deserve explanation:
Why "module": "CommonJS" and not "ESNext"?
The plan called for module: "ESNext" with moduleResolution: "Bundler". I tried it. It compiled — but ts-node at test time couldn't resolve the resulting ESM-flavored import paths without explicit .js extensions, and the dev loop became a flaky mess of ERR_MODULE_NOT_FOUND errors on core-js/... imports.
The fix is to keep CommonJS here — Phase 3 is about type correctness, not module format. The ESM cutover happens in Phase 5 when tsup becomes the build tool and emits its own .mjs files independent of the dev-time tsconfig. Each phase keeps the scope it owns.
Why "strictNullChecks" + "noImplicitAny" + "noImplicitThis" instead of "strict": true?
strict: true enables seven flags including strictFunctionTypes and strictPropertyInitialization. The latter would have triggered fresh errors in src/steem/serializer.ts (a 600-line file full of factory functions with deferred initialization) that aren't bugs — they're just patterns TS 5 fences off by default.
My choice: enable the three null/this/any flags that catch real bugs, skip the two that would have generated mechanical noise. Phase 5 or later can revisit.
Why "types": ["node", "verror"] not just letting TS auto-load?
Without an explicit types array, TS pulls in every @types/* package in node_modules. With Phase 5's planned playwright, c8, and mocha dev-deps, that would pull in @types/dom, @types/jest-shim stuff, and a bunch of overlapping Buffer declarations. Explicit is safer.
The Import-Shape Breakage
The legacy code used the namespace-import pattern for CommonJS modules:
import * as assert from 'assert'
import * as bs58 from 'bs58'
import * as ByteBuffer from 'bytebuffer'
import * as secp256k1 from 'secp256k1'
Without esModuleInterop, that worked because TS treated CommonJS modules as namespace objects, and assert(...) / new ByteBuffer(...) happened to work because the namespace property was the function/class.
With esModuleInterop: true enabled (which is required for modern TS), TS now treats the namespace as a true module record. Calling assert(...) fails:
src/crypto.ts(156,9): error TS2349: This expression is not callable.
Type 'typeof assert' has no call signatures.
The fix is to use default imports for CommonJS modules:
import assert from 'assert'
import bs58 from 'bs58'
import ByteBuffer from 'bytebuffer'
import secp256k1 from 'secp256k1'
I touched 7 source files for this:
src/client.ts,src/utils.ts,src/crypto.tssrc/helpers/broadcast.tssrc/steem/account.ts,src/steem/asset.ts,src/steem/serializer.ts
…and 11 test files. The change is mechanical but tedious.
Real Bugs Surfaced by Strict Mode
Beyond the import-shape churn, strict mode found genuine sloppy code:
src/client.ts:267 — implicit any parameter
- fetchTimeout = (tries) => (tries + 1) * 500
+ fetchTimeout = (tries: number) => (tries + 1) * 500
src/helpers/broadcast.ts:286 — implicit any[] array
- const extensions = []
+ const extensions: any[] = []
The original code initialized extensions = [] and pushed values later. TS 3 inferred any[]; TS 5 with strict says "this is a never[] until proven otherwise" and complains at the push site. An explicit annotation makes the intent visible.
src/helpers/rc.ts:73 — destructuring without types
- private _calculateManabar(max_mana: number, { current_mana, last_update_time }): Manabar {
+ private _calculateManabar(max_mana: number, { current_mana, last_update_time }: { current_mana: number | string; last_update_time: number }): Manabar {
TS 5 won't infer the type of a destructured parameter from usage. The annotation was always missing — TS 3 just didn't care.
src/steem/misc.ts:65 — encoding type narrowing
- public toString(encoding = 'hex') {
+ public toString(encoding: BufferEncoding = 'hex') {
return this.buffer.toString(encoding)
}
Buffer.toString() accepts BufferEncoding (a string literal union), not string. The default value 'hex' was getting widened to string and then rejected at the call site.
src/client.ts:269 — Response.json() returns unknown now
- const response: RPCResponse = await retryingFetch(...)
+ const response = await retryingFetch(...) as RPCResponse
In TS 5.1+, Response.json() is typed as Promise<unknown> (matching the actual web spec change). The retryingFetch helper propagates this. A cast at the call site is the honest answer — we know the shape of Steem RPC responses.
src/index-browser.ts:42 — delete on non-optional
- delete global['fetch']
+ delete (global as any)['fetch']
Strict TS forbids delete on non-optional properties. This whole code block gets gutted in Phase 5 anyway (it was an Edge Legacy workaround), but for Phase 3 we just keep it compiling.
test/misc.ts — Promise<unknown> resolve() needs an arg
- resolve()
+ resolve(undefined)
new Promise((resolve) => ...) now infers Promise<unknown>, and resolve() with zero args wants Promise<void>. Explicit undefined.
One Pragmatic Hack: secp256k1 Type Mismatch
The installed runtime is [email protected]. The @types/secp256k1@4 package types a different API (the renamed ecdsaSign/ecdsaVerify of v4+). So the types don't match the methods we actually call (.sign, .verify, .recover).
Since Phase 4 is about to delete this entire code path, fixing the types properly would be wasted effort. I did this instead:
import secp256k1Raw from 'secp256k1'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secp256k1: any = secp256k1Raw
A local cast to any, scoped to src/crypto.ts. Survives until tomorrow when @noble/curves replaces the whole thing and the cast goes away.
This is the kind of pragmatic shortcut you should always document with a comment explaining why it's there (a Phase 4 marker) and when it goes away. Otherwise it ages into permanent technical debt.
Phase 3 Test Result
$ npx tsc --noEmit
$ # (no output — clean)
$ npm run lint
✖ 43 problems (0 errors, 43 warnings)
$ npm test
48 passing (195ms)
tsc --noEmitclean against the new strict tsconfig- Lint stays at 0 errors
- All 48 tests still pass (same baseline as Phase 1)
- Both on Node 16 (CI) and Node 20 (dev) and Node 22 (Docker spot-check)
What Was Upgraded
"@types/node": "^10.12.9" → "^22.10.1"
"@types/mocha": "^5.2.5" → "^10.0.10"
"@types/bs58": (added) "^4.0.4"
"@types/secp256k1": (added) "^4.0.7" (mismatched; Phase 4 removes)
"typescript": "^3.1.6" → "^5.6.3" (already bumped in Phase 2 for ESLint)
"ts-node": "^7.0.1" → "^10.9.2" (already bumped in Phase 2)
The Meta-Lesson
Modernizing TypeScript across a 6-year gap is mostly a mechanical exercise — the language is genuinely good at backward compatibility. The hard parts are:
tsconfigphilosophy shift: TS 5 wantsesModuleInterop,skipLibCheck,forceConsistentCasingInFileNames. These are basically required for modern dev. They surface real bugs (case-sensitivity issues across Mac/Linux/Windows, broken CJS interop) but they also force you to fix code that was technically wrong but never failing.Knowing what to defer: I could have flipped
module: ESNextright now. It would compile. It would also have wreckedts-node. Discipline is recognizing that this correct change belongs to a different phase (Phase 5) where the build tool changes anyway.The
anyescape hatch is fine — temporarily: the secp256k1 cast is ugly, but it's scoped, commented, and dead in 24 hours. The crime isn't usingany; it's leavinganyin place after the structural reason for it is gone.
What's Next: Phase 4
Tomorrow is the highest-risk single change in the entire migration: ripping out the native [email protected] (the only package in our tree with a known high-severity CVE) and replacing it with @noble/curves + @noble/hashes — the same pure-JS crypto stack that ethers and viem use.
The classes (PublicKey, PrivateKey, Signature) must remain bit-compatible with v0.11.x. But the signatures they produce might not be bit-identical between the two ECDSA implementations. I'll walk through how the golden-vector test from Phase 0 caught exactly that — and why "bit-different but functionally equivalent" is actually the right answer.
How You Can Help
- 🔍 Watch the repo:
blazeapps007/dsteem - 🐛 Spot regressions: API compatibility with v0.11.x is non-negotiable
- 💬 Comment: questions about specific phase choices help shape the rest of the series
This work was developed with Claude AI assistance. All technical decisions reflect Steem ecosystem needs and the hard requirement of zero breaking changes for existing consumers.
Support Secure Steem Development
If you value proactive engineering, UX polish, and performance optimizations for the STEEM ecosystem, please consider supporting my witness: blaze.apps
🗳️ Vote Here:
Vote for blaze.apps Witness
Disclaimer: This post describes work in progress on the blazeapps007/dsteem fork. The release will be tagged v0.12.0 after Phase 8 ships next week.