Writing TypeScript without worrying about JavaScript
TypeScript's type system is very powerful. To make it compatible with the previously untyped code and APIs written in JavaScript, there had to be a lot of tools for dealing with oddly shaped data. Constructing a system beginning with types, rather than adding types after, leads to different architectures and different requirements from the type system. Not all features in the type system are necessary when the entire codebase has always been in TypeScript.
These are the principles and recommendations for specific features that I try to follow when I work on a TypeScript-first codebase, based on my experience with many codebases, big and small. The goal is to leverage the type system to create good APIs, architectures, and allow developers to have confidence and familiarity with the codebase.
Principles
These are my personal principles when writing TypeScript - it might not apply to you or your team. So to set the context, these are the principles I follow.
Consistency
Make code predictable.
Consistence is less important in small projects, teams, or codebases, but matters greatly as the team or codebase grows.
I prefer functional style over object oriented.
But being consistent in either style contributes to predictable APIs.
Be consistent not only with your own codebase, but with the APIs by frameworks you use.
Many JavaScript frameworks have been designed without types in mind, or as an afterthought. As a result, when working with them, it’s probably best to align with their conventions.
Conventions should be broken for exceptional cases.
High performance code has different requirements from low or normal performance code.
Most websites don’t have high performance needs, or clever-yet-complicated solutions.
The compiler
The compiler is there not only to prevent errors, but to guide developers in correct usage of APIs.
Type all top level functions, and in any place there could be ambiguity.
Small lambdas where there's no ambiguity don't need types, but it helps.
If in doubt, add a type.
All types should have a definition (or a name).
Except for a generic-type made specific (e.g number[]).
Keep types simple, small, and composed.
Enforcing types at compile time isn't enough: parse (don't validate) values at boundaries (e.g JSON.parse) at runtime.
Use union types as much as possible.
Organisation
Don’t abstract too early, or too rigorously.
Prefer bigger, logically connected files over many very small files.
Names are as important in types as they are for functions, parameters or variables.
Good types and naming isn't enough to replace docs or examples, but it massively helps explorative programming.
Write code with the default configuration of tsc in mind.
Specific type-system features
This advice is meant as “generally, this is what I try to follow”. There are many cases to deviate.
🤌 - the recommendation
📖 - why I recommend it
❌ - an example of what I wouldn’t do
✅ - an example of what I would do
🌦️- an example of what I might do, but not every time
🎨 - a stylistic recommendation
💭 - a thought on what would need to happen for me to change my preference
Config
🤌Write code consistently, regardless of configuration.
📖 Configuration fractures the community's practices - both in open source, and at work. If the project creator ran tsc init without changing any fields, the compiler features a developer is used to might not apply. These defaults change over time, too. While there are cases where the compiler will catch more errors, there's also some where it will catch less. Consistently following practices to ensure a project leverages the type system helps prevent unintentional mistakes.
Naming types
🤌Use CamelCase.
🤌Avoid using Hungarian Notation.
📖 LSPs can provide the type of anything a user hovers over, so there is no need to name something IUser or BIsPerson.
🤌If a type needs to be clearly separated from another type with the same name, consider reducing the public API (e.g expose User to the consumer of the API, but not internal metadata used).
📖 The user of an API shouldn’t need to understand the difference between FullyQualifiedUser and GoogleAccountUser, only the type of the User they pass in or receive back from an API.
Naming generics
🤌Avoid T, E as names, and prefer actual words.
📖 They don’t convey the meaning of the value or type.
🤌Use lowercase names, such as value or error.
📖 Built-in types are lowercase in TypeScript, but most other types are CamelCase. Using lowercase separates out the generic types from the known types.
🤌In the cases where a type could be anything, and there’s no natural name to convey the meaning, I personally prefer alphabetical names - a, b, c, etc.
📖 Alphabetical, starting at a, has a more natural sequence following than T. Would you begin a list with item 20?
❌
type Ok<T> = { kind: "Ok"; value: T };
type Err<E> = { kind: "Err"; error: E };
type Result<T, E> = Ok<T> | Err<E>;
✅
type Ok<value> = { kind: "Ok"; value: value };
type Err<error> = { kind: "Err"; error: error };
type Result<value, error> = Ok<value> | Err<error>;
Type vs Interface
🤌Prefer type. But it doesn’t matter too much which is used, as long as there is consistency.
📖 interface can be used to modify a definition after the initial definition (declaration merging), but I consider that an anti-feature except for dealing with gradual migration of a JavaScript codebase.
🎨 I personally find type definitions to be cleaner, but that’s my personal preference. type allows for aliases.
💭 It could become a smaller problem if editor tooling expanded an interface definition into the full type, so that the info is available on hover.
Keep definitions small
🤌 Avoid deeply nested (2+ levels) types, and extract them to a type definition.
📖 If a type contains several fields with deeply nested data, it’s often a good idea to extract a type definition out. The type definition can then be reused, for example with a function that only requires part of the data, or for creating composable parsers.
Kind
🤌 Give each type a kind property, matching the name of the type.
📖 This helps with exhaustive matching of types. Exhaustive matching helps catch all cases of a union type, and provides a way to approximate types at runtime. Technically kinds aren’t as important in types not used in unions, but it aids with consistency. Consistency makes a codebase predictable.
🤌 Provide a constructor for a type that takes the properties, and returns an object with the kind.
📖 A function constructor can hide internal fields that you don’t need the user to provide, and can make creating a value in a functional style easier (e.g from a parser).
✅
type User = {
kind: "User";
name: string;
age: number;
}
function User(name: string, age: number): User {
return {
kind: "User",
name,
age,
}
}
Enum and unions
🤌Use unions everywhere they make sense.
📖 Unions are a great way of dealing with branching code in a type-safe way. They combine multiple different types into one. Unions, when used correctly, I personally find to be great indicators of a types-first API.
🤌Prefer union types either with tagged members or literals over enums.
📖 Enums are useful for representing multiple items that represented a shared type, but may have overlapping values. However, union types are somewhat more natural to work with on the type level.
🤌Use separate types for each type in a union, each with a kind.
📖 Exhaustive path checking (e.g with a switch statement on the kind field) are one of the most powerful ways of ensuring that state is handled. If written in the right way, when a union tag is added or removed, the compiler will catch the cases that aren’t covered / don’t need to be covered.
✅
type Cat = {
kind: "Cat";
name: string;
lives: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9;
};
type Dog = {
kind: "Dog";
name: string;
hasLeash: boolean;
};
// adding a new member to this union will cause
// a type error in branches if not handled
// (as in the switch below)
type Animal = Cat | Dog;
function removeLife(cat: Cat): Cat { ... }
function toggleLeash(dog: Dog): Dog { ... }
function updateAnimal(animal: Animal): Animal {
switch (animal.kind) {
case "Cat": {
return removeLife(animal);
}
case "Dog": {
return toggleLeash(animal);
}
}
}
Predicates
🤌Use when narrowing types from a known union of types.
✅
type User = {
kind: "User";
name: string;
email: string;
age: number;
}
type InternalUser = {
kind: "InternalUser";
id: number;
}
function isUser(user: User | InternalUser): user is User {
if (user.kind === "User") return true;
return false;
}
🤌Avoid when the data is any - prefer parsing.
📖 It’s easy to mess up a predicate in a way the compiler won’t catch, if being used with any, and will lead to invalid code.
❌
function isUser(user: any): user is User {
if (user.kin === "User") {
// typo, kin is undefined,
// but compiler does not catch it since user is any
return true;
}
return false;
}
const user = User(...);
isUser(user) // false! but it should be true
Types on variables
Type inference makes it convenient to skip explicitly giving types to variables when assigning them. There’s some exceptions where I would explicitly set the type.
🤌When assigning an array to a value, assign the type.
📖 Otherwise an array is inferred from what is in it initially, but it may not match the intention. If an array is empty, it is inferred as any[]. Explicitly setting the type clarifies the intention to the reader, and the compiler.
❌
const names = []; // inferred: any[]
names.push("Noah"); // still any[]
names.push(123); // still any[]
const names = [] as const; // readonly []
names.push("Noah"); // compiler error - can't push to readonly
const names = [ "Noah" ] as const // readonly [ "Noah" ]
✅
const names: string[] = [];
names.push("Noah");
names.push(123) // type error - can't push a number to a string[]
🤌When assigning a variable to an object, explicitly set the type. Alternatively, use a constructor function to ensure a value is correct.
❌
const person = {
name: "Noah",
age: 30,
}; // inferred as { name: string, age: number }
const otherPerson = {
nam: "Jim",
age: 40,
}; // inferred as { nam: string, age: number }
✅
type Person = {
kind: "Person";
name: "Noah";
age: number;
};
const person: Person = {
kind: "Person",
name: "Noah",
age: 30,
};
const otherPerson: Person = {
kind: "Person",
nam: "Jim",
age: 40,
}; // type error: expected field name, got field nam
🤌 When explicitly setting the types, extract the type to a named type.
📖 Named types are easier to re-use, either for other data or for functions.
❌
const person: { kind: "Person"; name: "Noah"; age: number } = {
kind: "Person",
name: "Noah",
age: 30,
};
✅
type Person = { kind: "Person"; name: "Noah"; age: number };
const person: Person = {
kind: "Person",
name: "Noah",
age: 30,
};
Types on functions
🤌Give each parameter and the return value a type in the function signature, even when void. Don’t rely on return type inference.
📖 By default, TypeScript’s inference for function return types is too general. Nothing stops an unintended type from being returned, or a value being returned when void was intended. Making the return type explicit helps catch returns of an unintended type.
❌
function getScore(result: Result) {
if (result.positiveFeedback > 0) {
return 10;
}
return "0";
} // inferred as getScore(Result): 10 | "0" (number | string)
✅
function getScore(result: Result): number {
if (result.positiveFeedback > 0) {
return 10;
}
return "0"; // type error - expected a number but got a string
}
Record, and index signatures
🤌Use records as you would use dictionaries or hashmaps in other languages, i.e when a known object is composed of a keys of a specific type and the values are of a specific type, which will have new entries added at runtime.
🤌Avoid records in favour of normal type declarations when there is a fixed set of keys, e.g an object with known properties.
🤌Use a record as a nested field instead of extending a top-level type with other, existing, fields through index signatures. Separate the types instead of populating the root type.
📖 Separating the types makes it easier to pass specific pieces of a type to other functions, and prevents accidental type errors.
❌
type User = {
kind: "User";
name: string;
[ meta: string ]: string;
}
const user: User = { kind: "User", name: "Noah" };
user.nam; // undefined, but does not get caught by the compiler
✅
type MetaProperties = Record<string, string>;
type User = {
kind: "User";
name: string;
meta: MetaProperties;
};
const user: User = { kind: "User", name: "Noah", meta: {} };
user.nam; // no such field "nam", gets caught by the compiler
🤌Use index signatures when it is unclear what the keys and values are, but give the key a meaningful name.
❌
type Cache = Record<string, AST>;
✅
type Cache = {
[filename: string]: AST;
};
Partial, Required, unknown
🤌 Use Partial at boundaries, for making parsing a data source easier. If a field is truly optional beyond boundaries, use a Result, Maybe, or Either. If an entire definition is optional, use a Maybe.
📖 Partial is useful for dealing with data that might be missing a lot of expected field values - e.g, from JSON.parse. Working with as few optional fields as possible often makes code easier to read. Internal types benefit from knowing exactly what exists.
🤌 Use Partial over any when parsing isn’t an option, use unknown over any for parsing.
📖 any breaks any type safety or type-aware auto-completion. It doesn’t just break the type system at the point where any is used, it also breaks the type safety for anything that value is passed to. Partial provides some level of type safety.
Pick, Omit
Pick and omit is useful for defining a type that’s a subset of another type. Pick will keep fields, omit will remove them. These are particularly useful when a type is large, but only specific fields are required for an internal API.
🤌 Prefer explicit type definitions with the subset needed than Pick or Omit. This is easier for shallow types rather than deeply nested types.
📖 Pick and Omit based type definitions require the reader to keep the context of the root type in their mind. In the examples below, the reader must know what User contains in order to understand what Omit will keep. Pick requires the reader to know what the type of name and email are. The type definition must also be extended with a kind, if all your types have kinds.
💭 It could become a smaller problem if editor tooling expanded definitions using multiple utilities into the full type, so that the info is available on hover.
🌦️
type User = {
kind: "User";
name: string;
email: string;
age: number;
};
type SignUpDetails = Pick<User, "name" | "email"> & { kind: "SignUpDetails" };
type SignUpDetails = Omit<User, "kind" | "age"> & { kind: "SignUpDetails" };
type User = {
kind: "User";
name: string;
email: string;
age: number;
};
type SignUpDetails = {
kind: "SignUpDetails";
name: string;
email: string;
};
Extends, &
🤌Prefer explicit types over extends.
📖 Extending a type can help keep their relationship in sync, so that it’s not necessary to update field names or types in multiple places. If the codebase follows the kind practice of a unique kind for each type, then Omit can be used to remove the kind and apply a new one. However, this will then make the extended type incompatible with the extended one anyway. It can be worked around by having a private type containing only the raw data, but it’s awkward.
🌦️
type RawUser = {
name: string;
email: string;
age: number;
}
interface User extends Omit<User, "kind"> {
kind: "User";
}
interface InternalUser extends Omit<User, "kind"> {
kind: "InternalUser";
id: number;
}
💭 It could become a smaller problem if kind was established as a special field, and must have a new definition in the extended definition.
🤌Extend a max depth of 1 (i.e don’t chain extends).
📖 Extending a type isn’t a bad idea in general, but becomes problematic when a type extends a type extends a type extends a type and so on. It usually hints to me that a codebase has too many levels of abstraction and has over-applied DRY.
💭 It could become a smaller problem if editor tooling expanded a chain extended definition into the full type, so that the info is available on hover.
Infer
🤌Avoid using infer unless the signature is simple.
📖 infer is a very useful tool in the right situation, but often leads to signatures which are more complicated than if they were done manually. In the interest of keeping types simple, I personally would avoid them.
Keyof
🤌Use to create literal unions of an object’s fields.
As const
As const tells the compiler to infer the narrowest type definition for a specific value.
🤌Use when you have a list of union type options and don't want to maintain both the list and the type.
🌦️
const registeredSpecies = ["Dog", "Cat"] as const;
// this is the same as "Dog" | "Cat"
type RegisteredSpecies = (typeof registeredSpecies)[number];
🤌Use when you're prototyping something quickly, and want to focus on the implementation rather than the types.
🤌Avoid in most other cases. An explicitly typed out type is usually better at explaining the intention of the code, while restricting the shape of the data.
type RegisteredSpecies = "Cat" | "Dog"
const registeredSpecies: RegisteredSpecies[] = ["Dog", "Cat"];
Class
Classes can provide a lot of the features that make opaque API design easy, such as access modifiers for properties, constructors, and simpler setters/getters. These are enforced not just at compile time (i.e by the TypeScript compiler), but at runtime (i.e by the browser engine), since they are part of the ECMAScript standard. Using classes for libraries therefore can prevent users from depending on APIs intended to be internal or experimental.
If the developers for a project come from a traditional OOP background, they may prefer classes. If they come from a more functional background, they may prefer types and top-level functions. Choose one style, and align the codebase in that style.
Conclusion
Many of these lessons are ones I’ve learnt from my experiences with both ML-family languages that started with good types - Haskell, Elm, Idris, and those that added gradual types - Python, JavaScript. These lessons fed into my design of Derw, which aims to combine the TypeScript world with the ML-family world.
The best advice I could give is to learn the context of the people you work with, the codebases you work on, and what the principles and conventions they follow are. Principles should not always be followed, but it can help make code predictable.