🌸 lily's bunny blog 🐇
< back homelily's cascading styleguide
... for sane and (somewhat) readable typescript.
people get really worked up about code styles, fighting over what's "correct" and what isn't... so i thought i'd add fuel to the fire :)
here are the rules i personally (try to) follow when writing typescript (but also other languages).
one: consistency
whatever you do, apply it as consistently as you possibly can. this rule is at the very top because the "joke" is that this styleguide "cascades" like a cascading stylesheet (css), where general rules are placed by the top and more specific ones by the bottom [of the document].
a good example of this rule is the case you prefer. people will fight and argue endlessly about whether you should use camel or snake case, but often overlook the most important point: did you pick one and consistently use it? good programmers won't spend so much time thinking "ew this codebase is all snake case, it should be camel case!" but they, most certainly, will have a problem with using two if not three case styles within the same file.
there's many more examples to be made, like whether you put the curly braces on the line below the function definition or on the same line, but the point here is this: pick one, and stick with it.
two: don't do things inline
this may sound a little ambiguous but i've titled it this way so it effectively means: don't use ternaries or other similar fluff.
const data: TData & { animal: 'cat' | 'dog' | 'bird' | 'mouse' } = { ... };
let animal;
if (data.animal) {
switch (data.animal) {
case 'cat':
animal = 'feline friend';
break;
case 'dog':
animal = 'human\'s best friend';
break;
case 'bird':
animal = 'loud and squeeky!';
break;
case 'mouse':
animal = 'eats your floorboards up';
break;
}
}
return animal;
is a lot easier for the brain to read and process than:
const data: TData & { animal: 'cat' | 'dog' | 'bird' | 'mouse' } = { ... };
const animal =
data.animal === "cat"
? "feline friend"
: data.animal === "dog"
? "human's best friend"
: data.animal === "bird"
? "loud and squeeky!"
: data.animal === "mouse"
? "eats your floorboards up"
: undefined;
return animal;
don't you think?
and having said that, this applies to react (or any other spa framework really) development as well. don't do shit like this:
return (
<div>
<p>{colour === 'red' ? 'Danger!' : colour === 'yellow' ? 'Warning!' : colour === 'green' ? 'All good!' : null}</p>
</div>
);
instead, please just please, refactor into a function and pat yourself on the back:
function get_status_level(colour: string) {
switch (colour) {
case "red":
return "Danger!";
case "yellow":
return "Warning!";
case "green":
return "All good!";
default:
return null;
}
}
return (
<div>
<p>{get_status_level()}</p>
</div>
);
three: don't nest, refactor
excessive nesting is not just ugly but it indicates you have a problem.
when dealing with nested if/else statements, drop the else, invert the condition, and return early:
// don't do this
if (condition_1) {
if (condition_2) {
do_stuff();
} else {
cry_2();
};
} else {
cry_1();
}
// do this
if (!condition_1) {
cry_1();
return;
}
if (!condition_2) {
cry_2();
return;
}
do_stuff();
async/await
stuff instead of promise.then(...).catch(...)
:
// don't do this
stupid_async_task().then((result: unknown) => {
do_shit(result).then((result: unknown) => {
return do_something_else(result);
}).catch((exception) => {
return freak_out(exception);
});
}).catch((exception) => {
return handle_crap(exception);
});
// do this
let result: unknown;
try {
result = await stupid_async_task();
} catch (exception) {
return handle_crap(exception);
}
try {
return do_something_else(result);
} catch (exception) {
return freak_out(exception);
}
if you're really cool you refactor try/catch so that you don't have to use mutable variables to pull things out of the try/catch
scope.
four: control flow should be predictable (stop using exceptions)
control flow is the order in which things get executed in your program. your program might start with a switch statement executing different instructions based on the value of a variable, the "control" part of "control flow" are checks like these.
ensuring predictable control flow might sound easy at first but it can quickly become harder than one would expect. a good example is exceptions. in languages like JavaScript the default behaviour for exceptions (errors) is to crash your shit. to avoid this we catch the exception using a try/catch
block like so:
try {
do_something_stupid();
} catch (exception) {
do_something_dumber(exception);
}
and while this is fine it does a couple of undesirable things:
- we are now one level more nested (indented) than we were before (ugly).
- anything we do inside of the
try
block is now scoped to that block; we cannot declare a variable here and use it somewhere else. - we have just created a shitty version of a GOTO statement. if the logic inside the
try
block is any bit more complicated than a function call then we interrupt that control flow when an exception is thrown and jump to ourcatch
block (unpredictable). - if we have multiple exceptions to deal with we either need multiple
try/catch
blocks, nested within each other, or to do some extra handling in ourcatch
block. in the case of JavaScript specifically, we have to do stupidexception instanceof BullshitError
checks to even determine what error we're dealing with (assuming the error isn't entirely generic).
so... what to do?
use values. errors as values they say.
say you have a function that calls some api, plays around with the data it returns, and returns the data in some shape you desire. instead of throwing an exception when fetch fails you could just return an object with an error property:
export enum PokemonErrors {
NotOk,
DataUnprocessable
}
async function get_pokemon(name: string) {
const res = await fetch('https://pokeapi.co/api/v2/pokemon/' + name);
if (!res.ok) {
return { data: null, error: PokemonErrors.NotOk };
}
const data = await res.json();
if (!data) {
return { data: null, error: PokemonErrors.DataUnprocessable };
}
return {
data: {
abilities: data.abilities,
meta: {
height: data.height,
default: data.is_default,
name: data.name,
order: data.order
}
},
error: null
}
}
now you can call the function and simply check if the error property is set:
const { data: pokemon, error } = get_pokemon('ditto');
if (error) {
switch (error)
case PokemonErrors.NotOk:
...
break;
...
}
}
// do something with `pokemon`.
now, admittedly, other libraries and in-built functions in JavaScript (and other languages) might not adhere to these principles so you may still have to try/catch
stuff. but worry not, there is a way to deal with that as well:
/**
* Asynchronously try calling a function, return its value, and if it fails,
* return its error.
*
* @param func async function to unwrap.
* @returns Promise of `{res, err}`. Either `res` will be of type T and `err`
* will be `null` or `res` will be `null` and `err` will be `unknown` or an
* `Error`.
*/
export async function unwrapAsync(
func: () => Promise
): Promise<{ res: T, err: null } | { res: null, err: unknown | Error }> {
try {
const res = await func();
return { res, err: null };
} catch (e) {
return { res: null, err: e };
}
}
the above is one of the two functions exported by my @spaugur/eav npm package. it's dead simple (and i kinda recommend you copy-paste the source in favour of installing it), all it does is run something in a try/catch
block and returns the result and the error in an object. that way you can keep control flow sane while working with things like fetch that may throw an exception:
import { unwrapAsync as unwrap } from "@spaugur/eav";
// unwrap the value and exception
const { res, err } = await unwrap(async () => {
throw new Error('Something bad happened.');
// we can pretend this return is sometimes reachable...
return { test: 'data' };
});
// handle any errors
if (err || !res) {
if (err instanceof Error) {
console.log('Error!', err.message);
return;
}
console.log(err);
return;
}
// consume the value
console.log(res);
that's it
this is all i've got. thanks for reading! though if you're looking for some drama-starters here they are:
- use tab, convert to spaces (by your editor)
- 4 space indentation, not 2, not 8
- always camel case
- interfaces not types (ts)
- never
export default ...
alwaysexport ...