Idiomatic Programming
31. March, 2023 • 7 min read • Teach
Code that looks like the language it is written in
Idiomatic code is code that follows the conventions of the language it is written in, rather than the conventions of the language you learned first. It is the difference between TypeScript that reads like TypeScript and TypeScript that reads like Java with the type annotations moved around.
Nobody enforces this. The compiler does not care, the tests do not care, and the code ships either way. What you get from it is that the next person, quite possibly you in eight months, can read the file without stopping to work out what the author was doing. Conventions are worth knowing even when they are not enforced, which is roughly the point I was making about class name ordering a few months back.
Below are the patterns I lean on most in TypeScript. Most of them have equivalents in other languages, so the habit transfers even if the syntax does not.
Describe your shapes with interfaces
An interface gives a name to a shape, and that name then shows up in every error message and every autocomplete popup instead of a wall of inline braces:
interface User {
name: string;
age: number;
email: string;
}
const sendEmail = (user: User, message: string): void => {
// do stuff
};
const user: User = {
name: 'John',
age: 30,
email: 'john@wick.com',
};
sendEmail(user, 'Hello!');Note where the void goes. The return type belongs to the function, not to the constant holding it. Writing const sendEmail: void = ... is a type error, and it is one I see a lot in code that was ported over from somewhere else.
Let the compiler do the inferring
TypeScript is good at working out types on its own, and annotating things it already knows is noise:
// inferred as string
const name = 'John';
// inferred as (a: number, b: number) => number
const add = (a: number, b: number) => {
return a + b;
};
const result = add(2, 3);Annotate the boundaries, infer the middle. Parameters need annotations because there is nothing to infer from. Return types usually do not, unless the function is part of a public API and you want the compiler to shout at you when the shape drifts.
Promises, and the catch that quietly eats your errors
Async work is everywhere, and there is a specific mistake worth calling out:
// don't do this
const fetchData = (): Promise<unknown> => {
return fetch('/api/data')
.then(response => response.json())
.catch(error => console.error(error));
};That .catch swallows the rejection. The promise it returns resolves with undefined, so any .catch the caller attaches never fires and the caller happily carries on with nothing. If you are going to handle an error, handle it. If you are not, let it propagate:
const fetchData = async (): Promise<Article[]> => {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
return response.json();
};Two other things happen here. async/await flattens the chain, and the return type is Article[] rather than any. response.json() returns Promise<any>, which means every value that flows out of it is unchecked from that point on. Give it a type, or use unknown and validate.
readonly, where it earns its place
The readonly modifier stops a property from being reassigned after construction:
interface Person {
readonly name: string;
age: number;
}
const person: Person = { name: 'John', age: 30 };
person.name = 'Bob'; // ERROR: Cannot assign to 'name' because it is a read-only propertyThis is a compile-time check only. Nothing stops the value being mutated at runtime, and nothing stops a plain Person object being passed somewhere that expects a mutable one. It is a hint to readers backed by the compiler, which is still worth having. ReadonlyArray<T> (or readonly T[]) does the same job for arrays and is the more useful of the two in my experience, because accidental push into a prop is a real bug and accidental reassignment of a field usually is not.
Enums, and why I mostly avoid them
The usual advice is to use an enum for a fixed set of named constants:
enum Color {
RED = 'red',
GREEN = 'green',
BLUE = 'blue',
}
const setBackgroundColor = (color: Color) => {
document.body.style.backgroundColor = color;
};
setBackgroundColor(Color.BLUE);This works, and if your codebase already uses enums everywhere then consistency beats my preference. But enums are the one part of TypeScript that emits actual JavaScript. Everything else erases to nothing; an enum leaves an object behind in your bundle. And const enum, which is the version that does not, is unsupported under isolatedModules, so it breaks the moment you build with esbuild, Babel or anything built on them.
A union of string literals gives you the same exhaustiveness checking with no runtime cost:
type Color = 'red' | 'green' | 'blue';
const setBackgroundColor = (color: Color) => {
document.body.style.backgroundColor = color;
};
setBackgroundColor('blue');You lose the Color.BLUE namespacing. If you want it back, an object with as const and a derived type gets you most of the way there. You might not agree, and I have worked in codebases where the enum version reads better. This is a preference, not a rule.
Generics for the things that genuinely vary
A generic lets one function serve many types without collapsing into any:
function getFirst<T>(array: T[]): T | undefined {
return array[0];
}
const numbers = [1, 2, 3];
const firstNumber = getFirst(numbers); // number | undefined
const strings = ['hello', 'world'];
const firstString = getFirst(strings); // string | undefinedThe | undefined in the return type is the interesting part. Indexing an array in TypeScript is not checked by default, so array[0] on an empty array is typed T while actually being undefined. Writing it out is honest. Turning on noUncheckedIndexedAccess makes the compiler do it for you everywhere, which is the sort of setting that produces a hundred errors on day one and saves you a production incident later.
Generics are also where idiomatic code goes wrong most often. Three type parameters with conditional types and a mapped type on top will typecheck beautifully and be unreadable. If you cannot explain the signature out loud, it is too clever.
A few more worth knowing
I will not list every pattern in the language, but these come up constantly:
- Type guards and narrowing. A function returning
value is Articleteaches the compiler something it cannot work out alone. This is how you get fromunknownto a real type safely. unknowninstead ofany.anyswitches type checking off;unknownforces you to narrow first. Anything crossing the boundary into your app, JSON, form data, a query string, should arrive asunknown.- Nullish coalescing and optional chaining.
a?.b ?? fallbackhandlesnullandundefinedwithout also swallowing0and''the way||does. - Destructuring. Cheap readability, especially for props and function options objects.
neverfor exhaustiveness. Assign to aneverin the default branch of a switch and the compiler fails the build when someone adds a new case to the union. Pairs perfectly with string literal unions.
Tuples, decorators, namespaces and currying all have their moments, but reaching for them without a reason is how you end up with the clever generic signature nobody can read.
Where to actually start
Do not try to adopt all of this at once. Pick the two settings that pay off immediately, strict and noUncheckedIndexedAccess, and let the compiler tell you where the codebase disagrees with itself. Fix those, and most of the patterns above turn up on their own because they are the shortest path to making the errors go away.
The rest is reading. Whatever language you are in, the fastest way to learn its idioms is to read its standard library and a couple of well-regarded projects, and notice what they do that you would not have.
‘Till next time!