dsteem Modernization Plan -Phase 2

in Steem Dev2 days ago (edited)

Day 2: tslint was officially deprecated in 2019 — the same year dsteem stopped getting commits. Today I replaced it with ESLint 9 flat config and typescript-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 --fix cleaned up automatically, and the 5 it couldn't — one of which was a real bug.

dsteem-phase-2-eslint.png


  • 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 @noble crypto 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.0 release

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 legacy tslint:recommended triple-equals: allow-undefined-check behavior
  • '@typescript-eslint/no-explicit-any': 'off' — the original tslint.json had "ban-types": false. The codebase uses any deliberately 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:

FileOldNew
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:112while (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:96new 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.json to npm 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.