mcpField
P
push-null-to-perimeter

push-null-to-perimeter

github.com/marius-townhouseDevelopment644 installsUpdated 38w ago
Repository

About push-null-to-perimeter

A TypeScript skill for designing related nullable values as fully present or fully absent, reducing scattered null checks. It is for developers shaping types and refactoring optional properties.

Key features

  1. 01Group related values into all-or-nothing types
  2. 02Use discriminated unions for partial states
  3. 03Keep null handling at system boundaries
  4. 04Use factories for asynchronous initialization

Use cases

  1. 01Refactor types with related optional fields
  2. 02Reduce scattered null checks
  3. 03Model loading and error states
  4. 04Avoid partially initialized classes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/marius-townhouse/effective-typescript-skills --skill push-null-to-perimeter

After installing, type/push-null-to-perimeterin Claude Code to confirm it loaded.

Details

From the project README

Push Null Values to the Perimeter of Your Types

Overview

Design types so values are either completely null or completely non-null, not a mix.

Mixed null states create implicit relationships that are hard to track and lead to scattered null checks and bugs.

When to Use This Skill

  • Designing types with multiple nullable fields
  • Finding null checks scattered throughout code
  • Related values that are null together
  • Debugging "undefined is not an object" errors
  • Refactoring types with many optional properties

The Iron Rule

NEVER design types where null values have implicit relationships.

No exceptions:

  • Not for "it's simpler"
  • Not for "we check at runtime"
  • Not for "the fields are independent"

Detection: The "Mixed Null" Smell

If two values are null together or non-null together, express that in the type.

// ❌ VIOLATION: Implicit relationship between min and max
function extent(nums: number[]) {
  let min: number | undefined;
  let max: number | undefined;
  
  for (const num of nums) {
    if (min === undefined) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);  // Error! max might be undefined
    }
  }
  return [min, max];  // [number | undefined, number | undefined]
}

// Caller has to deal with all four combinations:
const [min, max] = extent([1, 2, 3]);
// min defined + max defined
// min undefined + max undefined
// min defined + max undefined  <- Impossible but allowed by type!
// min undefined + max defined  <- Impossible but allowed by type!

Solution: All-or-Nothing Types

// ✅ CORRECT: Result is either fully present or fully absent
function extent(nums: number[]): [number, number] | null {
  let result: [number, number] | null = null;
  
  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }
  return result;
}

// Caller only has two cases:
const result = extent([1, 2, 3]);
if (result) {
  const [min, max] = result;  // Both guaranteed to exist
}

Example: User with Posts

// ❌ BAD: Mixed nullability
class UserPosts {
  user: UserInfo | null;
  posts: Post[] | null;
  
  constructor() {
    this.user = null;
    this.posts = null;
  }
  
  async init(userId: string) {
    this.user = await fetchUser(userId);
    this.posts = await fetchPosts(userId);
  }
}

// At any moment, four states are possible:
// user null + posts null      (before init)
// user null + posts non-null  (during init - race condition!)
// user non-null + posts null  (during init - race condition!)
// user non-null + posts non-null  (after init)

// ✅ GOOD: All-or-nothing
class UserPosts {
  user: UserInfo;
  posts: Post[];
  
  private constructor(user: UserInfo, posts: Post[]) {
    this.user = user;
    this.posts = posts;
  }
  
  static async create(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPosts(userId),
    ]);
    return new UserPosts(user, posts);
  }
}

// Only two states: no instance, or fully loaded instance
const userPosts = await UserPosts.create(userId);
console.log(userPosts.user.name);  // Always safe!

Example: API Response

// ❌ BAD: Data and error both optional
interface ApiResponse {
  data?: ResponseData;
  error?: Error;
  loading: boolean;
}

// Confusing states are possible:
const bad: ApiResponse = {
  data: someData,
  error: someError,  // Both data AND error?
  loading: true,     // Still loading but has data?
};

// ✅ GOOD: Each state is complete
type ApiResponse =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: ResponseData }
  | { status: 'error'; error: Error };

// No confusion possible:
function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case 'idle':
      return null;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <Data data={response.data} />;  // data guaranteed
    case 'error':
      return <Error error={response.error} />;  // error guaranteed
  }
}

The Boundary Pattern

Handle nullability at the boundaries of your system, not throughout:

// ❌ BAD: Null checks everywhere
function processUser(userId: string | null) {
  if (!userId) return null;
  const user = getUser(userId);
  if (!user) return null;
  const posts = getPosts(user);
  if (!posts) return null;
  return formatUserWithPosts(user, posts);
}

// ✅ GOOD: Check at boundary, then work with clean types
function processUser(userId: string | null): UserWithPosts | null {
  // Handle null at the boundary
  if (!userId) return null;
  
  const user = getUser(userId);
  if (!user) return null;
  
  // After validation, work with non-null types
  return formatUserWithPosts(user);  // Takes User, not User | null
}

function formatUserWithPosts(user: User): UserWithPosts {
  // No null checks needed inside - user is guaranteed non-null
  const posts = user.posts;  // Always exists
  return { ...user, posts: posts.map(formatPost) };
}

Class Design: Fully Initialized or Not At All

// ❌ BAD: Partially initialized state
class Connection {
  socket: Socket | null = null;
  protocol: Protocol | null = null;
  
  async connect() {
    this.socket = await createSocket();
    this.protocol = await negotiateProtocol(this.socket);
  }
  
  send(data: string) {
    if (!this.socket || !this.protocol) {
      throw new Error('Not connected');
    }
    this.protocol.send(this.socket, data);
  }
}

// ✅ GOOD: Factory ensures complete initialization
class Connection {
  private constructor(
    private socket: Socket,
    private protocol: Protocol,
  ) {}
  
  static async create(): Promise<Connection> {
    const socket = await createSocket();
    const protocol = await negotiateProtocol(socket);
    return new Connection(socket, protocol);
  }
  
  send(data: string) {
    // No null checks - always initialized
    this.protocol.send(this.socket, data);
  }
}

Pressure Resistance Protocol

1. "We Need Partial States"

Pressure: "The object needs to exist before all data is loaded"

Response: Create a separate type for the partial state, or use a factory.

Action: Use discriminated unions or async factories.

2. "It's More Complex"

Pressure: "One type with optional fields is simpler"

Response: Scattered null checks are more complex than clean types.

Action: Invest in the type design upfront.

Red Flags - STOP and Reconsider

  • Multiple optional fields that are null together
  • Null checks scattered throughout a class
  • Race conditions in async initialization
  • Comments like "X is only set when Y is set"
  • "Impossible" states that the type allows

Common Rationalizations (All Invalid)

ExcuseReality
"It's simpler"Scattered null checks aren't simple.
"We check at runtime"Types catch errors at compile time.
"Fields are independent"If they're null together, they're related.

Quick Reference

PatternSolution
Two values null togetherReturn tuple or null
Object with loading stateUse discriminated union
Class with async initUse static factory method
Mixed nullable propertiesGroup into nested object

The Bottom Line

Make null an all-or-nothing proposition.

Design types so a value is either completely present or completely absent. Push null handling to the boundaries of your code. The result is cleaner types, fewer null checks, and fewer bugs.

Reference

Based on "Effective TypeScript" by Dan Vanderkam, Item 33: Push Null Values to the Perimeter of Your Types.

Comments

Markdown supported