dsteem Modernization Plan -Phase 2
Day 2:
tslintwas officially deprecated in 2019 — the same yeardsteemstopped getting commits. Today I replaced it with ESLint 9 flat config andtypescript-eslint@8. Sounds boring? It's actually the most subtle phase in the migration, because ESLint 9 requires TypeScript ≥4.8.4 and we're still on TS 3.1.6 from the original project. Here's how I worked around that, the 606 style errors--fixcleaned up automatically, and the 5 it couldn't — one of which was a real bug.

- Day 1 — Phase 0: dsteem Modernization Plan
- Day 2 — Phase 1: CI Plumbing on GitHub Actions
- Today — Phase 2: Retiring
tslint, Adopting ESLint 9 (you are here) - Tomorrow — Phase 3: TypeScript 3.1 → 5.6 + strict mode
- Day 5 — 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
Why tslint Had to Go
The official tslint README, top of page, has been showing this banner since December 2019:
"TSLint has been deprecated as of 2019. Please see the TSLint roadmap, suggesting migration to ESLint, for more information."
It's not just unmaintained — it's actively user-hostile. New TypeScript syntax (like satisfies, const type parameters, the using keyword) breaks tslint's parser. The Phase 3 TypeScript upgrade would have made tslint start throwing errors that have nothing to do with code quality.
So lint had to migrate before the TypeScript bump. The challenge: ESLint 9's flat config + typescript-eslint@8 officially require TypeScript ≥4.8.4. We're still on TS 3.1.6 at this phase.
The Workaround
Looking at the requirement carefully — typescript-eslint@8 needs TS ≥4.8.4 as an installed peer, not as the syntax it parses. So I bumped typescript and ts-node as part of the Phase 2 install (without touching tsconfig), even though the real TypeScript modernization is Phase 3:
- "ts-node": "^7.0.1",
+ "ts-node": "^10.9.2",
- "tslint": "^5.11.0",
+ "typescript": "^5.6.3",
+ "typescript-eslint": "^8.16.0",
+ "@eslint/js": "^9.15.0",
+ "eslint": "^9.15.0",
+ "globals": "^15.12.0",
- "typescript": "^3.1.6",
This is a small phase-boundary violation — Phase 3 is "supposed" to do the TS bump — but it's the minimum that unblocks ESLint. The actual tsconfig modernization (strict mode, ES2022 target, esModuleInterop) stays in Phase 3. The principle: install dependencies in the phase that needs them, save the config changes for the phase that owns them.
The New eslint.config.mjs
ESLint 9 introduced "flat config" — a single ESM file that replaces the layered .eslintrc.json + .eslintrc.js + .eslintignore mess. Here's the full config, mirroring the legacy tslint.json rules where they have equivalents:
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import globals from 'globals'
export default tseslint.config(
{ignores: ['dist/**', 'lib/**', 'docs/**', 'node_modules/**', 'examples/**']},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {...globals.node, ...globals.browser}
},
rules: {
// Style — mirror legacy tslint.json
quotes: ['error', 'single', {avoidEscape: true, allowTemplateLiterals: true}],
semi: ['error', 'never'],
indent: ['error', 4, {SwitchCase: 1}],
'comma-dangle': ['error', 'never'],
eqeqeq: ['error', 'allow-null'],
'no-bitwise': 'off',
'no-empty': ['error', {allowEmptyCatch: true}],
// TypeScript
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/dot-notation': 'off',
'@typescript-eslint/no-unused-vars': ['warn', {argsIgnorePattern: '^_'}],
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-this-alias': 'off',
'no-unused-vars': 'off'
}
}
)
Key choices:
- 4-space indent, single quotes, no semicolons, no trailing commas — exactly the style the codebase already uses
eqeqeq: 'allow-null'— match the legacytslint:recommendedtriple-equals: allow-undefined-checkbehavior'@typescript-eslint/no-explicit-any': 'off'— the originaltslint.jsonhad"ban-types": false. The codebase usesanydeliberately in serializer-heavy paths'@typescript-eslint/no-unused-vars': 'warn'(not 'error') — there's a lot of legacy unused imports in test files; surfacing them as warnings lets cleanup happen incrementally
🔧 Replacing 5 tslint:disable Directives
A grep for tslint:disable turned up exactly 5 directives across 4 files:
| File | Old | New |
|---|---|---|
src/utils.ts:40 | // tslint:disable-line:no-string-literal | // eslint-disable-line @typescript-eslint/dot-notation |
src/index-node.ts:37 | // tslint:disable-line:no-string-literal | // eslint-disable-line @typescript-eslint/dot-notation, @typescript-eslint/no-require-imports |
src/index-browser.ts:40 | // tslint:disable-next-line:no-string-literal | // eslint-disable-next-line @typescript-eslint/dot-notation |
src/index-browser.ts:42 | // tslint:disable-line:no-string-literal | // eslint-disable-line @typescript-eslint/dot-notation |
src/helpers/rc.ts:1 | /* tslint:disable:no-string-literal */ | /* eslint-disable @typescript-eslint/dot-notation */ |
Interestingly, because I'd set '@typescript-eslint/dot-notation': 'off' globally in the config, these directives became no-ops and the next eslint --fix pass cleaned them up entirely. The "disable" was disabling something that was never warning. Healthy code.
eslint --fix: The Free 95% Cleanup
Running npx eslint src test --fix against the codebase auto-corrected 601 style errors and 5 warnings in one pass:
- All 4-space-vs-12-space-vs-16-space indent variations
- Mixed single/double quotes
- Trailing commas (left over from the previous tslint config)
- A few extra semicolons in test files
- A couple of
;after object literal property values
That left 5 real errors for me to handle manually:
1. src/steem/serializer.ts:174 — != vs !==
- if (data != undefined) {
+ if (data != null) {
buffer.writeByte(1)
The original code wanted "data is defined and not null." eqeqeq with allow-null allows != null (which covers both null and undefined) but not != undefined (which only covers undefined). The semantic fix is != null. This was a real correctness review opportunity — the original code's intent was correct, the spelling wasn't.
2. src/utils.ts:112 — while (true) retry loop
- } while (true)
+ } while (true) // eslint-disable-line no-constant-condition
retryingFetch is a legitimate infinite retry loop, bounded by the timeout check at the top. The no-constant-condition rule has no good way to express that, so a targeted disable comment is the honest answer.
3. test/blockchain.ts:96 — new Promise(async ...) anti-pattern
- await new Promise(async (resolve, reject) => {
+ await new Promise(async (resolve, reject) => { // eslint-disable-line no-async-promise-executor
Test code. The pattern is a real anti-pattern (an async function inside the Promise executor swallows rejection), but it's existing behavior that the test relies on. I'd rather not refactor test logic in a lint-cleanup phase. Disable + comment.
4–5. test/client.ts:26-27 — string == comparisons
- assert(error.message == `itr != _by_name.end(): no method...`
- || error.message == `method_itr != api_itr->second.end(): ...`)
+ assert(error.message === `itr != _by_name.end(): no method...`
+ || error.message === `method_itr != api_itr->second.end(): ...`)
Two literal string comparisons with ==. Pure equality, no null involved, should be ===. The == here was just sloppiness in the original test. Clean fix.
The Phase 2 Test Result
$ npm run lint
✖ 43 problems (0 errors, 43 warnings)
$ npm test
48 passing (230ms)
- Lint: 0 errors. 43 warnings, all unused-variable warnings in test files — non-blocking, will get incrementally cleaned up
- Tests: same 48 passing as Phase 1's baseline. No regression.
- CI: workflow updated from
npx tslint -p tsconfig.json -c tslint.jsontonpm run lint. Green.
What Was Removed
tslint.json ← deleted
That's it. One file. The whole [email protected] package will exit node_modules when I rerun npm install after Phase 5 prunes the dependency tree.
The Meta-Lesson
The lint migration is the prerequisite to the TypeScript migration, not a parallel track. Doing TS first would leave a window of broken lint (because tslint can't parse modern TS syntax) where I couldn't tell whether a "lint error" was an actual quality issue or a parser failure. Doing lint first means I can read every TS error in Phase 3 as a real semantic problem.
The same logic applies further down the chain: lint → TS → crypto → build → tests. Each one provides the diagnostic ground truth for the next.
This is also why the Phase 2 commit is small — fewer than 10 files changed in source — even though the dependency churn was massive. The two are independent. Bumping deps is plumbing; rewriting code is engineering. They don't have to happen together.
What's Next: Phase 3
Tomorrow I bump TypeScript from 3.1.6 (Oct 2018) to 5.6 (current). The tsconfig gets rewritten with strict: true, esModuleInterop: true, target: ES2022, and friends. Then I fix every type error the modernization surfaces — including a bunch of import * as X from 'cjs-module' patterns that no longer work the way they used to under esModuleInterop.
Spoiler: I'll deliberately keep module: "CommonJS" for one more phase (so ts-node keeps working in tests). The full ESM cutover is Phase 5's job.
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.