This document branches from:
- Google TypeScript Style Guide: https://google.github.io/styleguide/tsguide.html
It is intended to be a single, standalone reference.
This guide uses RFC 2119 terminology:
- must / must not: required
- should / should not: recommended
- may: optional
- prefer / avoid: correspond to should / should not
Applies to all source code written in TypeScript and developed with intent to be part of Hiero-Ledger Solo projects.
Source files are encoded in UTF-8.
Aside from the line terminator sequence, the ASCII horizontal space character (0x20) is the only whitespace character that appears anywhere in a source file. This implies that all other whitespace characters in string literals are escaped.
For any character that has a special escape sequence (\', \", \\, \b, \f, \n, \r, \t, \v), that sequence is used rather than the corresponding numeric escape (e.g \x0a, \u000a, or \u{a}). Legacy octal escapes are never used.
For remaining non-ASCII characters, use the actual Unicode character (e.g. ∞). For non-printable characters, equivalent hex or Unicode escapes (e.g. \u221e) can be used along with an explanatory comment.
Use 120 characters per line.
Files consist of the following, in order:
- Copyright information, if present
- JSDoc with
@fileoverview, if present - Imports, if present
- The file’s implementation
Exactly one blank line separates each section that is present.
License or copyright information is required in all source code files.
- An SPDX license identifier must be present.
A file may have a top-level @fileoverview JSDoc. If present, it may provide a description of the file's content, its uses, or information about its dependencies. Wrapped lines are not indented.
There are four variants of import statements in ES6 and TypeScript:
- module import:
import * as foo from '...';(TypeScript imports) - named import:
import {SomeThing} from '...';(TypeScript imports) - default import:
import SomeThing from '...';(only for other external code that requires them) - side-effect import:
import '...';(only to import libraries for their side-effects on load)
- TypeScript code must use paths to import other TypeScript code.
- Prefer relative imports (
./foo) rather than absolute imports (path/to/foo) when referring to files within the same logical project. - Consider limiting deep parent traversals (
../../../).
- Prefer named imports for frequently used symbols or clear names (e.g.
describe,it). - Prefer namespace imports when using many different symbols from large APIs.
Prefer resolving name collisions via namespace imports or renaming exports; renaming imports (import {SomeThing as SomeOtherThing}) is allowed when needed.
When every identifier in an import statement is used only as a type (not as a value at runtime),
the type modifier must be used. This is enforced by the
@typescript-eslint/consistent-type-imports ESLint rule at the error level.
Use the inline form when an import contains a mix of types and values:
// All type-only → import type
import type {Facade} from './facade.js';
// Mixed value + type → inline type modifier on the type-only identifiers
import {SomeClass, type SomeInterface} from './some-module.js';task format will auto-fix violations automatically.
Rationale: Explicit type imports are erased completely by the TypeScript compiler and help bundlers with tree-shaking. They also reduce the risk of circular-dependency issues at runtime since modules that contain only type imports do not create a runtime dependency edge.
When working with filesystem path operations in Solo code:
- Prefer
PathExfromsrc/business/utils/path-ex.tsinstead of directnode:pathimports. - Do not introduce new direct
path.join(...),path.resolve(...),path.relative(...), orpath.basename(...)calls when aPathExequivalent exists. - Keep path handling centralized through
PathExto preserve consistency and safety guidance in one place.
Use named exports in all code.
Do not use default exports.
Only export symbols used outside of the module. Minimize exported API surface.
Files exporting module-scope behavior functions are unwanted. Do not export implementation helpers, parser details, or single-call-site glue functions just so another file can reach into a module; keep them private or replace them with a deliberate, domain-level API.
Mutable exports can create hard-to-debug code; export let is not allowed.
If externally accessible mutable bindings are required, provide explicit getter functions.
Do not create container classes with static methods/properties purely for namespacing. Export constants and functions instead.
Each exported interface and class should be in its own file with the file name in kebab-case all lowercase matching the interface/class.
Rationale: helps prevent circular dependencies and makes items easier to find.
- Always use
constorlet. - Use
constby default. - Never use
var. - Variables must not be used before their declaration.
Do not use let a = 1, b = 2;.
- Do not use the
Array()constructor. - Do not define properties on arrays other than numeric indices and
length. - Spread syntax is allowed for shallow copy/concatenation, but only spread iterables.
- Array destructuring is allowed; omit unused elements.
- Do not use the
Objectconstructor. - Do not use unfiltered
for (... in ...)over objects. - Use
Object.keys,Object.values,Object.entrieswithfor (... of ...)or filter withhasOwnProperty. - Spread syntax is allowed for shallow copies, but only spread objects (not arrays or primitives).
- Class declarations must not be terminated with semicolons.
- Class expressions used as statements must end with a semicolon.
- Do not use semicolons between methods.
- Separate methods by a single blank line.
- Avoid private static methods when module-local functions suffice.
- Do not rely on dynamic dispatch of static methods.
- Avoid static
thisreferences.
- Always use parentheses in constructor calls:
new Foo(). - Avoid unnecessary constructors.
- Separate constructor from surrounding code by a single blank line.
- Do not use
#privatefields. Use TypeScript visibility modifiers. - Use
readonlyfor properties not reassigned outside the constructor. - Prefer parameter properties when appropriate.
- Initialize fields at declaration when possible.
- Limit symbol visibility as much as possible.
- TypeScript symbols are public by default.
- Always specify
public,private, andprotectedmodifiers. - Default to
privatefirst, then relax visibility only when needed.
- Prefer function declarations for named functions.
- Do not use function expressions; use arrow functions instead.
- Use concise arrow bodies only when the return value is actually used.
- Avoid rebinding
this; prefer arrow functions and explicit parameters. - Prefer passing arrow functions as callbacks when higher-order functions might pass unexpected arguments.
- Avoid arrow function properties on classes except for cases like uninstallable event handlers.
Only use this in class constructors/methods, functions with an explicit this type, or arrow functions defined where this is valid.
- Use single quotes.
- Do not use line continuations inside string literals.
- Prefer template literals over complex concatenation.
- Use
0x,0o,0blowercase prefixes. - Never include a leading zero unless it is immediately followed by
x,o, orb.
- Use
String()andBoolean()(nonew), template literals, or!!to coerce. - Do not convert enum values to booleans using
Boolean()/!!or implicit coercion; compare explicitly. - Use
Number()to parse numeric values and validate forNaN/non-finite values as needed. - Do not use unary
+for string-to-number coercion. - Do not use
parseInt/parseFloatexcept parsing non-base-10 strings, and validate inputs first.
- Always use braced blocks for control flow statements.
- Exception: single-line
if (x) x.doFoo();is allowed. - Prefer
for (... of ...)for arrays. for (... in ...)is only for dict-style objects and must guard withhasOwnProperty.- Prefer truthy/falsy checks for string presence in conditionals (for example,
if (!localBuildPath)andif (app)), instead of comparing to''.
When assigning a fallback value only when the current value is falsy, prefer ||= over an if block.
// Avoid
if (argv[flags.context.name]) {
argv[flags.context.name] = this.configManager.getFlag<Context>(flags.context);
}
// Prefer
argv[flags.context.name] ||= this.configManager.getFlag<Context>(flags.context);- Instantiate errors using
new Error(). - Only throw
Error(or subclasses). - Assume caught errors are
Error; use narrowing whenunknown. - Empty catch blocks are strongly discouraged; explain with a comment if truly needed.
- Keep try blocks focused.
- All switches must have a
defaultgroup, and it must be last. - No fallthrough for non-empty case groups.
- Always use
===and!==. - Exception: comparison to literal
nullmay use==or!=to cover bothnullandundefined.
- Avoid
asassertions and!non-null assertions unless there is an obvious/explicit reason. - Prefer runtime checks.
- Type assertions must use
assyntax (not angle brackets). - For unsafe “double assertions”, cast through
unknown. - For object literals, prefer type annotations (
: Foo) overas Foo.
- Do not define new decorators.
- Only use framework decorators (e.g. Angular, Polymer).
- No empty lines between a decorator and the decorated symbol.
- Do not instantiate wrapper objects:
new String,new Boolean,new Number. - Do not rely on Automatic Semicolon Insertion. End statements with semicolons.
- Do not use
const enum. - No
debuggerstatements. - Do not use
with. - Do not use
evalorFunction(...string)except code loaders. - Do not use non-standard ECMAScript or Web Platform features.
- Never modify built-in objects.
- Use only ASCII letters, digits, underscores (constants and structured test names), and rarely
$.
- Do not decorate names with type information included in the type.
- Do not use trailing or leading underscores for private properties or methods.
- Exception: underscore prefix is allowed for private variables when using
getandsetkeywords for getter/setter methods. - When using the
ofandfromprefixes for method names:- use
offor simple object creation - use
fromfor transformation
- use
Names must be descriptive and clear to new readers. Avoid ambiguous abbreviations and internal-letter deletions.
The unicorn/prevent-abbreviations ESLint rule enforces this at the error level. Common
violations to avoid:
| Avoid | Use instead |
|---|---|
fn |
function_ (suffix _ avoids the keyword) |
vars |
variables |
env / envVar |
environment / environmentVariable |
cb |
callback |
err |
error |
e (event/error) |
event / error |
src |
source |
dest / dst |
destination |
dir |
directory |
val |
value |
prop |
property |
res |
result / response |
req |
request |
msg |
message |
arg / args |
argument / arguments_ |
num |
number |
str |
string_ |
buf |
buffer |
ctx |
context |
opts |
options |
cfg / conf |
config / configuration |
cmd |
command |
tmp |
temporary |
idx |
index |
len |
length |
acc |
accumulator |
cur / curr |
current |
prev |
previous |
attr |
attribute |
param |
parameter |
The same rule applies to file names. For example, a test file must be named
solo-config-environment-variable-override.test.ts, not
solo-config-env-var-override.test.ts.
Treat acronyms as words: loadHttpUrl, not loadHTTPURL.
UpperCamelCase: class / interface / type / enum / decorator / type parameters / component functions in TSX / JSXElement type parameterlowerCamelCase: variable / parameter / function / method / property / module aliasCONSTANT_CASE: global constant values, including enum values (see constants section)
Type parameters may use T or UpperCamelCase.
xUnit tests may use _ separators: testX_whenY_doesZ().
Identifiers must not use _ as a prefix or suffix. _ must not be used as an identifier by itself.
Namespace imports are lowerCamelCase while files may be snake_case.
CONSTANT_CASE is for global constants and signals “do not modify”, even if not deeply frozen.
When referencing command flags in code and tests:
- Do not hardcode flag keys as string literals when a
flagsconstant exists. - For config-object keys, use
flags.<flag>.constName(for example,[flags.deployment.constName]). - For argv/CLI option names, use
flags.<flag>.name(for example,argv[flags.deployment.name]). - Avoid string literals such as
'deployment','nodeAliasesUnparsed', and'--deployment'in places whereflagsconstants are available.
- Add explicit types to avoid generics inferring
unknown(e.g. empty collections). - Always specify the type for declarations.
- Rationale: improves readability without an IDE (code review, GitHub, text editor), improves type checking, and can speed compile time.
- This applies equally in test files: every
const/letmust carry an explicit type annotation, and every callback passed todescribe()/it()/beforeEach()/afterEach()must declare its return type (: voidfor synchronous,: Promise<void>for async).
- Always specify return types.
- Rationale: readability, stronger checking, and improved compile-time performance.
- Arrow functions passed as callbacks are not exempt. Use
(): void =>for synchronous callbacks andasync (): Promise<void> =>for asynchronous callbacks:
// correct
describe('my suite', (): void => {
it('my test', (): void => { ... });
it('async test', async (): Promise<void> => { ... });
beforeEach(async (): Promise<void> => { ... });
});
// incorrect — missing return types
describe('my suite', () => {
it('my test', () => { ... });
});- Use either
undefinedornulldepending on context. - Do not bake
|nullor|undefinedinto type aliases.
Prefer optional parameters/fields with ? instead of explicit |undefined.
- Use interfaces to define structural types.
- For structural implementations, annotate the type at the declaration site.
For object types, prefer interface over type literal alias.
- Prefer
T[]andreadonly T[]for simple element types. - Use
Array<T>for complex element types.
- Prefer
Map/Setover{[k: string]: T}when possible. - When using index signatures, provide meaningful key labels.
May be used, but prefer the simplest construct. Interfaces and explicit property declarations are often easier to understand and maintain.
Avoid any. Prefer:
- a more specific type
unknown- a documented, suppressed lint warning (e.g. tests)
Avoid {} in most cases. Prefer unknown, Record<K, T>, or object depending on intent.
Prefer tuple types for “pair” use-cases, but consider named object properties for readability.
Do not use String, Boolean, Number, Object types. Use string, boolean, number, and appropriate object types.
Avoid creating APIs with return-type-only generics.
All TypeScript files must pass type checking.
- Do not use
@ts-ignore(these are warnings in Hedera systems and are being removed and turned into errors). - Instead, use
@ts-expect-errorand provide the error being ignored as the description (or a strong explanation).
Adhere to applicable conformance rules (e.g. security patterns like avoiding eval and unsafe DOM assignment).
- Use
/** ... */JSDoc for documentation consumers should read. - Use
// ...for implementation comments.
Use multiple // lines, not /* */ block comments.
- JSDoc is written in Markdown.
- Use Markdown lists where appropriate.
- Most tags occupy their own line.
- Line-wrapped block tags are indented four spaces.
- Do not indent wrapped
@descor@fileoverviewdescriptions.
Document exported symbols and anything whose purpose is not obvious.
Do not add types in @param/@return or tags like @private, @override, etc. when TypeScript keywords already express them.
Use parameter name comments when meaning is unclear:
someFunction(obviousParam, /* shouldRender= */ true, /* name= */ 'hello');
JSDoc must come before decorators; never place JSDoc between decorator and decorated statement.
If a style question is not settled, follow existing style within the file; otherwise follow directory conventions.
- Do not require full restyling of old files unless significant changes are being made.
- Avoid opportunistic style churn in unrelated CLs.
Use @deprecated with clear migration instructions.
Generated code is mostly exempt, but identifiers referenced from hand-written code must follow naming requirements.
This section complements the syntactic rules above with the design principles the project enforces during code review. Where the rules in §§2–9 say how to write a line, this section says whether the line should exist.
Every piece of knowledge — a constant, a computation, an option object, a workflow matrix dimension — should have a single, unambiguous representation in the codebase.
- Two occurrences: consider extraction. Note it in the PR.
- Three or more occurrences: extract. Duplication that has appeared three times will appear a fourth.
- Two near-identical methods on the same class: prefer adding a parameter to one method over keeping both. Multiple near-identical methods cause confusion about which to call.
- Identical or near-identical blocks of 10+ lines.
- Parallel
if/elsechains orswitcharms whose only difference is the type token they branch on. - Multiple call sites computing the same derived value from the same inputs.
- Multiple workflow files whose only difference is a matrix dimension — collapse into a single matrix.
- New method whose body overlaps an existing one by more than the unique slice of behavior — enhance the existing method instead of adding a sibling.
- Two methods that happen to read similar but mean different things (e.g.,
listNamespacesandlistPodsboth call the K8s API in a similar shape — they are different concepts). - Test fixtures that are similar by accident — clarity in tests outweighs brevity.
- Configuration that looks repeated but represents independent decisions (e.g., two flags that happen to default to
false).
Premature extraction is also a DRY violation in disguise — see §10.3.5.
Before introducing a new helper, search for an existing one with the same responsibility:
- Path operations →
PathEx(src/business/utils/path-ex.ts). See §3.3.5. - Kubernetes API error handling →
KubeApiResponse.throwError. - Listr task defaults →
constants.LISTR_DEFAULT_OPTIONS.DEFAULT. - CLI flag references →
flags.<flag>.constName/flags.<flag>.name. See §5.2.6. - Custom errors →
SoloError(or a subclass insrc/core/errors/).
If the existing utility is close but not quite right, enhance it rather than adding a parallel one. Two near-identical APIs is a bigger DRY problem than one slightly-more-flexible API.
SOLID applies to TypeScript with one caveat: the project's class-and-static-methods convention (see §3.4.5 in spirit, and the project's broader practice) is the dominant unit of behavior. Apply SOLID at the level of classes, static methods, modules, and service boundaries (between Solo, the container images in solo-containers, and the docs in solo-docs).
A class, method, or module should have one reason to change.
Within Solo:
- A command file orchestrates; computation belongs in
src/core/services. - A K8s client wrapper translates errors and shapes responses; it should not embed business logic.
- A resolver (e.g., "what storage class do we use?") should be its own class with its own static method, not inlined into the consumer.
Across repos (the most common SRP violation in review):
- Lifecycle behavior belongs in the container image, not in Solo's
kubectl execstrings. If Solo istouching supervisor files, removingdownmarkers, or polling for JVM startup, the responsibility is in the wrong layer — push it intosolo-containers. - Documentation belongs in
solo-docs, not indocs/site/content/en/docs/. Files under the in-repodocs/site/content/are migrating out; do not invest in them. - Version pinning of upstream images belongs to a release cut of the upstream repo — do not pin
mainto alpha/RC versions whose source isn't on the upstream repo'smainbranch.
When a PR adds Solo code that compensates for missing behavior elsewhere, ask: "who should own this?" If the answer is "the image" or "the chart" or "the docs repo", route the fix there.
Code should be open for extension, closed for modification.
In practice: when a new component type, node type, or cluster type is added, the change should plug into existing extension points (registries, lookup tables, discriminated unions) — not require editing every if/else chain across the codebase. If you find yourself editing N parallel chains, the chains themselves are the issue (§10.1.2).
Subtypes must be usable wherever their supertype is expected without surprising the caller.
In practice:
- Don't override a method to throw
Error('not supported')— if the type doesn't support the operation, the operation doesn't belong on the parent interface. - Don't override a method to weaken its postconditions (e.g., parent returns a non-empty list, subtype can return empty silently).
- If a subtype needs different behavior, prefer composition or an explicit capability flag over LSP-violating overrides.
Clients shouldn't be forced to depend on methods they don't use.
In practice:
- Prefer narrow interfaces (
PodLister,PodDeleter) over fat ones (PodEverything) when consumers truly need different slices. But don't pre-split — split when a second consumer with a different need appears (§10.3.5). - A class with one static method does not need a constructor or injected dependencies — see §4.4.4 and the project's preference for plain static helpers when no state is involved.
High-level modules should not depend on low-level modules; both should depend on abstractions.
In practice:
- Commands depend on services via injected interfaces (
tsyringe-neo+InjectTokens), not on concrete implementations. - A new external system access (a new K8s resource, a new external API) should go through an
integration/wrapper that exposes abusiness/-shaped interface; consumers depend on the interface. - Tests should be writable against the interface without standing up the real backend — if a unit test requires a live cluster, the abstraction is leaking.
The principles above produce these concrete review rules. Each is enforced in code review with the cited section as the rationale.
Behavior (resolvers, orchestrators, computations) is grouped on a class with static methods. Pure data (constants, types, simple factories) may remain as exports. See §3.4.5 — the prohibition on container classes applies to namespace-only containers; behavior with multiple related methods belongs together on a class.
If a top-level helper is only called from inside one class, move it inside the class as a private (or private static) method. Keeps cohesion high and avoids module-level API surface that nobody outside the file uses.
Flag descriptions in src/commands/flags.ts must not reference a single command, sub-command, or component. The same flag may be used by multiple commands and its description must read sensibly everywhere it appears. This is a special case of SRP: the flag's "reason to change" is the flag's concept, not any one command's wording preference.
Within a layer, all error paths take the same shape. Drift across files in the same module is a DRY violation against an implicit shared utility — call it out as such.
DRY is about removing proven duplication, not anticipating future duplication. A new helper "for symmetry" with code that doesn't yet exist is a YAGNI violation. Wait for the second concrete caller before extracting.
When a feature flag, generic, or interface exists only for a hypothetical second consumer that never materialized, prefer inlining and deleting the abstraction.
When a resolver handles user input, it must articulate the full cascade — typically:
- User-supplied value (validate against the environment).
- Environment default (e.g., cluster default
StorageClass). - Auto-detect (e.g., look for a class matching a known provisioner).
- Bootstrap (install the dependency, then use it).
Don't ship a cascade with one of these missing — backwards compatibility breaks when the user upgrades and their previously-implicit path stops working. See §10.4.
Default behaviors are part of the public contract. Changing what happens when a flag is omitted is a backwards-incompatible change, even when the flag was technically optional before.
- If existing users of the unmodified command relied on the old default, preserve it.
- If a new flag is required, default it to the value that reproduces the old behavior.
- If a removed flag had observable side effects, document the removal in the PR and CHANGELOG and confirm there is no script depending on it.
Solo runs on macOS, Linux, and Windows. Code that only works on POSIX shells is not portable.
- Use
PathExfor filesystem paths, notnode:path. See §3.3.5. - Avoid shelling out from TypeScript when a programmatic alternative exists.
- For
Taskfile.ymlfiles that the user is expected to run, prefer task-native commands over embedded bash. - When a Windows-incompatible approach is unavoidable, gate it on
os.platform()and provide a Windows-native fallback.
Unit tests are cheap, fast, and run on every change. E2E and nightly tests are slow and expensive. Before adding a new E2E or scheduled job, ask whether the regression you're guarding against can be caught at the unit level with a mocked dependency.
New scheduled workflows must justify the cost: what regression do they catch that the existing matrices don't, and why can't that regression be caught by a unit test?
- Google TypeScript Style Guide: https://google.github.io/styleguide/tsguide.html