Guerrilla Types in TypeScript
Renegade Type Design

Guerrilla Types in TypeScript
In this article, we’ll explore three intriguing (possibly terrible?) techniques to assist in type design!
The main goal is consistent and predictable Model/Entity/Class interfaces.
- Approaches to Designing Types
- Technique #1: Why not all
- Technique #2: Mix-ins
- Technique #3: Organizing with Namespaces
- Summary
Approaches to Designing Types
You’ve probably encountered or written varying patterns around “type implementations.” Especially when consuming data from 3rd party APIs.
Note: I’m intentionally ignoring “traditional” processes building Entity Relationship Diagrams (ERD) or Object Oriented Programming (OOP) inheritance hierarchies. Here, we’re building types to represent semi-structured API data.
Let’s explore two high-level approaches: Single large object (Top-down) vs. Multiple named types (Bottom-up.)
Single large object
Prioritizes being explicit over reusability & DRY-ness.
Bonus: IDE/Dev Experience is great, since tooltips include a more complete preview - without fuss.
interface ProductDetails { name: string; seller: { name: string }; availability: Array<{ warehouseId: string; quantity: number }>; reviews: Array<{ authorId: number; stars: number }>;}
Since we are prioritizing explicit readability, it’s okay to indulge in some repetition (within reason.) When groups of properties repeat many times, feel free to extract the repeated fields to a named type.
Multiple named types
Prioritizing reusability & DRY-ness.
This approach is likely the favored approach by a wide margin.
interface ProductDetails { name: string; seller: Seller; reviews: Reviews[]; availability: Availability[];}interface Seller { name: string; }interface Availability { warehouseId: string; quantity: number; }interface Reviews { authorId: number; stars: number; }
Overall, this approach is great. But it’s not without drawbacks.
- Readability is excellent at first; however, it can suffer as the size & number of types grows.
- Relentlessly DRY, but at what cost? (More on this later.)
- Developer experience can suffer since tooltips are less informative.
⚠️ Since (approximately) TypeScript v3, the Language Server truncates tooltips, omitting nested properties. 💡 There are tricks to improve things a bit. Try holding
Cmd or Ctrl
, then hover over various type names - you should see at least one extra ‘layer’ of properties in the tooltip.
Why do we have to choose between these two approaches? (Big ol’ type vs. Named sub-types.)
Technique #1: Why not all
Can we have it all?
- Clarity of “big-picture” types?
- Plus named sub-types?
- Without duplication?
✅ YES! 🎉
export interface ProductDetails { name: string; seller: { name: string }; reviews: Array<{ authorId: number; stars: number }>; availability: Array<{ warehouseId: string; quantity: number }>;}export type Seller = ProductDetails["seller"];export type Review = ProductDetails["reviews"][number];export type Availability = ProductDetails["availability"][number];
- Create large “Primary” structured types.
- Export sub-types derived from the Primary type.
This approach really shines in systems where “high-level” objects benefit from documentation in one place. Also, this technique supports re-use between many use cases: Models, Services, Query Results, etc.
Technique #2: Mix-ins
This strategy is all about putting together the right fields, with the right names, to represent single logical objects. The goal is to efficiently address multiple use cases with TypeScript Utilities and Type Unions.
This approach differs from traditional OOP inheritance & hierarchies, which aims to create layers of objects into tightly bound taxonomies. The mix-in approach is about flat and loosely-related types, grouping related fields while reducing duplication.
Mix-in Examples
interface TodoModel { text: string; complete: boolean;}interface InstanceMixin { id: number;}/** TodoDraft represents Form state, possibly all undefined */export type TodoDraft = Partial<TodoModel>;/** Todo represents a Todo instance record from the database */export type Todo = TodoModel & InstanceMixin;
Example User
interface User { id: number; name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}
Let’s represent the User
before & after saving to the database.
// Core User fields (say for a <form>)interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Fields from the databaseinterface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}// A User **instance** - with all fieldstype UserInstance = InstanceMixin & UserBase;
Now we can sculpt the exact fields we need (like password
for create/update, but not included in queries of UserInstance
).
interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}interface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}/** User payload for signup, including `password` field */export type UserPayload = UserBase & { password: string };/** Represents User type returned from server. */export type UserInstance = UserBase & InstanceMixin;
- “Is this a good practice?”
- “Should I try it out?”
No idea. Let’s keep going!
Technique #3: Organizing with Namespaces
Here, we declare a ModelMixins
namespace. This provides some organization plus a clearer reuse pattern.
Standardized Shapes
createdAt
&updatedAt
exist together.id
, notID
or_id
.
// `src/types/mixins.d.ts`namespace ModelMixins { interface Identity { id: number; } interface Timestamp { createdAt: Date; updatedAt: Date; } type Instance = ModelMixins.Identity & ModelMixins.Timestamp; interface HashedPassword { passwordHash: string; } interface InputPassword { password: string; }}
Using Type Unions
// `src/types/user.d.ts`export interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Single `User` type, using Type Union to dynamically// represent the pre- & post-creation states.export type User = | (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword) | (UserBase & ModelMixins.InputPassword);
If desired, you can also export individual named types:
/** User payload for signup, including `password` field */export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;/** Represents User type returned from server. */export type UserInstance = UserBase & ModelMixins.InputPassword;
Real-world Usage
Here’s an upsert()
function that uses in
operator to distinguish between UserInstance
and UserPayload
types.
function upsert(user: User) { if ("id" in user) { // TypeScript knows `user` here has fields from Instance (id, createdAt, etc) return updateUser(user.id, user); } else { // TypeScript knows this must be the `UserBase & ModelMixins.InputPassword` version of user. return createUser(user); }}
Summary
We covered three techniques and a few related supporting ideas.
You may be asking, are these good patterns? Should I adopt some of these ideas?
Resources
- TypeScript tips for legacy projects: Type only you need
- Matt Pocock’s Excellent new book
- Total TypeScript Tips