any globally in
typescript, ignoring integer clippy type warnings in Rust, etc.Make heavy use of types to define behaviour. In general introducing a type can be thought of as introducing a unit test. For example:
struct PositiveTime(i64);
impl TryFrom<i64> for PositiveTime {
type Err = ();
fn try_from(n: i64) -> Result<Self, Self::Err> {
if n < 0 {
return Err(());
}
return Ok(Self(n));
}
}
This can be thought of reducing the valid range of i64 to one we prefer (given that i64 is the native Linux time type but often we do not want these) that we can enforce a compile-time. The benefit in types over unit tests is simply use-at-site of a type ensure behaviour everywhere and reducing the amount of unwanted behaviour in a codebase.
Currently we do not try hard enough to isolate behaviours through types.
Avoid monolithic event handlers, and avoid state handling in logic. Some stateful code in our repos mixes the logic handling with the state handle code which produces very long, hard to reason about code which ends up as a rather large inline state machine:
Good:
function handleEvent(e, state) {
switch(e.type) {
case Event.Websocket: handleWebsocketEvent(e, state.websockets);
case Event.PythNet: handlePythnetEvent(e, state.pyth_handle);
case ...
}
}
Bad:
function handleEvent(e) {
// Many inlined state tracking vars. Not much better than globals.
var latestPythNetUpdateTime = DateTime.now();
var clientsWaiting = {};
var ...
switch(e.type) {
// lots of inline handling
}
}
Avoid catch-all modules, I.E: types/, utils/
Favor Immutability and Idempotency. Both are a huge source of reducing logic bugs.
State should whenever possible flow top-down, I.E: create at entry point and flow to other components. Global state should be avoided and no state should be hidden in separate modules.
Good:
// main.ts
function main() {
const db = db.init();
initDb(db);
}
Bad:
// main.ts
const { db } = require('db');
function() {
initDb(); // Database not passed, implies global use.
}
For types/functions that are only used once, keep them close to the definition. If they are re-used, try and lift them only up to a common parent, in the following example types/functions only lift as far as they are useful:
Example File Hierarchy:
lib/routes.rs:validateUserId()
lib/routes/user.rs:type RequestUser
lib/routes/user/register.rs:generateRandomUsername()
Good:
// Definition only applies to this function, keep locality.
type FeedResponse = {
id: FeedId,
feed: Feed,
};
// Note the distinction between FeedResponse/Feed for DDD.
function getFeed(id: FeedId, db: Db): FeedResponse {
let feed: Feed = db.execute(FEED_QUERY, [id]);
return { id, feed: feed, }
}
Bad:
import { FeedResponse } from 'types';
function getFeed(id: FeedId, db: Db): FeedResponse {
let feed = db.execute(FEED_QUERY, [id]);
return { id, feed: feed, }
}
Map functionality into submodules when a module defines a category of handlers. This help emphasise where code re-use should happen, for example:
Good:
src/routes/user/register.ts
src/routes/user/login.ts
src/routes/user/add_role.ts
src/routes/index.ts
Bad:
// src/index.ts
function register() { ... }
function login() { ... }
function addRole() { ... }
function index() { ... }
Not only does this make large unwieldy files but it encourages things like
types/ catch alls, or unnecessary sharing of functionality. For example
imagine a usernameAsBase58 function thrown into this file, that then
looks useful within an unrelated to users function, it can be tempting to
abuse the utility function or move it to a vague catch-all location. Focus
on clear, API boundaries even within our own codebase.
When possible use layered architecture (onion/hexagonal/domain driven design) where we separate API processing, business logic, and data logic. The benefit of this is it defines API layers within the application itself:
Good:
// web/user/register.ts
import { registerUser, User } from 'api/user/register.ts';
// Note locality: one place use functions stay near, no utils/
function verifyUsername( ...
function verifyPassword( ...
// Locality again.
type RegisterRequest = {
...
};
function register(req: RegisterRequest): void {
// Validation Logic Only
verifyUsername(req.username);
verifyPassword(req.password);
// Business Logic Separate
registerUser({
username: req.username,
password: req.password,
});
}
// api/user/register.ts
import { storeUser, DbUser } from 'db/user';
function registerUser(user: User) {
const user = fetchByUsername(user.username);
if (user) {
throw "User Exists;
}
// Note again that the type used here differs from User (DbUser) which
// prevents code breakage (such as if the schema is updated but the
// code is not.
storeUser({
username: user.username,
password: hash(user.password),
});
}