Let AI write emails and messages for you 🔥

Writing code the linear way

Gourav Goyal

Gourav Goyal

Sep 15, 2023

Below are the characteristics of a well written code:

  1. Easy to understand by others.
  2. Easy to recall by you.
  3. Easy to refactor by you and others.

Linear coding style helps in that. When I refer to "linear code", I generally mean code that is written in a straightforward, one-directional top-down manner without nested structures, which is more readable because it tends to follow a single, simple path of execution. Linear code simplifies debugging since developers can more easily predict the program’s behavior at each step. Going through the code step by step, it becomes straightforward to identify and isolate bugs. Remember that code is read much more often than it is written.

There are 2 rules of thumb for keeping code linear:

  1. Avoid deeply nested code.
    • Avoid ternary operators that span multiple lines.
    • Avoid deeply nested if-else conditions.
    • Implement exception handling at only one place. Add a try-catch block to only the outermost function, not to its inner functions. If you want to add try-catch blocks to inner functions for logging purposes, you should also throw the error afterward.
  2. Return as early as possible.

React Example: Linear vs non-linear code

Let's explore this through an example where a component fetches and displays user data:

Non-linear code

  • Uses nested ternary operators for conditional rendering.
  • The code flow is harder to follow due to nesting and multiple conditions in one block.
  • Adding more conditions would increase complexity and reduce readability.
import React, { useState, useEffect } from 'react';

// Non-linear approach
const UserProfileNonLinear = () => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUserData()
      .then(userData => {
        setUser(userData);
        setIsLoading(false);
      })
      .catch(err => {
        setError(err);
        setIsLoading(false);
      });
  }, []);

  return (
    <div>
      {isLoading ? (
        <p>Loading...</p>
      ) : error ? (
        <p>Error: {error}</p>
      ) : user ? (
        <div>
          <h1>{user.name}</h1>
          <p>Email: {user.email}</p>
          {user.isAdmin && <p>Admin User</p>}
        </div>
      ) : (
        <p>No user data available</p>
      )}
    </div>
  );
};

Linear code

  • Uses early returns for different conditions.
  • The code flows from top to bottom in a straightforward manner.
    • Avoid using ternary operator if code is spread across multiple lines.
  • The main render logic only appears after all conditions are checked.
import React, { useState, useEffect } from 'react';

// Linear approach
const UserProfileLinear = () => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUserData()
      .then(userData => {
        setUser(userData);
        setIsLoading(false);
      })
      .catch(err => {
        setError(err);
        setIsLoading(false);
      });
  }, []);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  if (!user) return <p>No user data available</p>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
      {user.isAdmin && <p>Admin User</p>}
    </div>
  );
};

JS Example: Linear vs non-linear code

Non-linear code

The code flow goes deeper into nested blocks before reaching the final result.

// Non-linear approach
function processUserNonLinear(user) {
  let result = null;

  if (user) {
    if (user.age >= 18) {
      if (user.isSubscribed) {
        result = `Welcome, ${user.name}! Enjoy premium content.`;
      } else {
        result = `Welcome, ${user.name}! Consider subscribing for premium content.`;
      }
    } else {
      result = "Sorry, you must be 18 or older.";
    }
  } else {
    result = "Invalid user data.";
  }

  return result;
}
// Usage
const user = { name: "Alice", age: 25, isSubscribed: true };
console.log(processUserNonLinear(user));

Linear code

  • Uses early returns to handle edge cases first.
  • The code flows from top to bottom without deep nesting.
// Linear approach
function processUserLinear(user) {
  if (!user) return "Invalid user data.";
  if (user.age < 18) return "Sorry, you must be 18 or older.";
  
  const baseMessage = `Welcome, ${user.name}!`;
  if (user.isSubscribed) return `${baseMessage} Enjoy premium content.`;
  return `${baseMessage} Consider subscribing for premium content.`;
}

// Usage
const user = { name: "Alice", age: 25, isSubscribed: true };
console.log(processUserLinear(user));

Designing the Architecture of an API

Let’s take the example of building a robust, production-grade API that generates AI responses from a user prompt. The following checks are applied:

  1. First handle basic validations like whether API body is correct, user authentication, and authorization.
  2. Then, truncate the user prompt if it’s too long, then generate the AI response and return it.
  3. If any other error occurs, switch to a second set of API keys but use the same model.
  4. If the error persists, attempt to use a fallback model from a different provider.
  5. If the error still persists, return the error as all options have been exhausted.

Writing the code for this can lead to deeply nested structures due to multiple catch blocks, which should be avoided.

Instead of using catch blocks in multiple places, you can catch the error once and return a response like this: {data, error}.

Here’s a rough architecture:

API file:

export default async function handler(req, res) {
  try {
  
    // validate request body type and throw error if it fails
    const { userPrompt, model } = validateRequestBody(req);

    // authenticate user
    const user = handleAuthentication(req);

    // authorize user: has quota exceeded, is subscription payment overdue, etc
    handleAuthorization(user);

    // validate prompt length and truncate if needed
    prompt = validateAndTruncatePrompt(userPrompt, model);

    // generate response
    const { response, error } = await generateResponse(prompt, model);

    if (response) {
      return res.status(200).json({ response });
    }
    
    // handle issue: all attempts to generate a response failed
    
  } catch (err) {
    // handle exception
  }
}

// Function to generate AI response with fallbacks
async function generateResponse(prompt, model) {

  const { data, error } = await generateResponseWithMainApiKey(prompt, model);

  // tip: return early
  if (data) {
    return data;
  }

  const { data, error } = await generateResponseWithBackupApiKey(prompt, model);

  if (data) {
    return data;
  }

  const { data, error } = await generateResponseWithAltModel(prompt, model);

  return { data, error };
}

utils.ts file:

function generateResponseWithMainApiKey(prompt, model) {
  return GenerateAIResponse(prompt, model, "mainApiKey");
}

function generateResponseWithBackupApiKey(prompt, model) {
  return GenerateAIResponse(prompt, model, "BackupApiKey");
}

function `(prompt, mainModel) {
  const alternateModel = getAlternateModel(mainModel);
  return GenerateAIResponse(prompt, alternateModel, "mainApiKey");
}

function GenerateAIResponse(prompt, model, apiKey) {
  // generate response implementation
}

Notice that I’ve created wrapper functions (generateResponseWithMainApiKey, generateResponseWithBackupApiKey, and generateResponseWithAltModel) for the underlying GenerateAIResponse function. It is necessary to do so because the code must be written according to business logic. Creating wrapper functions with descriptive names is the way to do that. If I don’t create wrapper functions, then it becomes harder to follow the business logic. For example, in the code below, the GenerateAIResponse function is called three times with different parameters. You can certainly add comments for explanation, but comments are easier to miss and can become outdated. However, wrapper functions with descriptive names are the best approach.

// Below code lacks business logic

async function generateResponse(prompt, model) {

  const { data, error } = GenerateAIResponse(prompt, model, "mainApiKey");

  // return early
  if (data) {
    return data;
  }

  const { data, error } = GenerateAIResponse(prompt, model, "BackupApiKey");

  if (data) {
    return data;
  }

  const alternateModel = getAlternateModel(mainModel);
  const { data, error } = await GenerateAIResponse(
    prompt,
    alternateModel,
    "mainApiKey"
  );

  return { data, error };
}

Benefits of the linear approach:

  1. Reduced cognitive load: Developers can understand the function's behavior by reading it once from top to bottom.
  2. Readability: The code is easier to follow as it progresses linearly from top to bottom.
  3. Maintainability: Adding or modifying conditions is simpler as each case is handled separately.
  4. Clearer logic: Edge cases are handled upfront, making the main logic more apparent.

Also read my other article: The art of organizing code

That's all, folks!
Hiring React Devs (in IST timezone) for my AI Startup.

Gourav's Newsletter
I write about startups, tech, productivity, personal development, and more.
Gourav Goyal

Gourav Goyal