Dan Levy's Avatar DanLevy.net

Guerrilla Types in TypeScript

Renegade Type Design

Guerrilla Types in TypeScript

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

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.

⚠️ 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?

✅ 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];
  1. Create large “Primary” structured types.
  2. 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 database
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
// A User **instance** - with all fields
type 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;
  1. “Is this a good practice?”
  2. “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

// `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

Edit on GitHubGitHub