TypeScript Variables and Functions: Type-Safe Declaration and Usage

July 25, 2025

☕️ Support Us
Your support will help us to continue to provide quality content.👉 Buy Me a Coffee

You know that feeling when you're working on a feature, everything seems to be working fine, and then a user reports that your article publishing system is broken? You investigate and discover that somewhere in your code, a function expected a number but received a string, or someone passed null instead of an article object, and your entire feature crashed.

If you've written JavaScript for more than a few weeks, you've probably experienced this. The problem isn't that you're a bad programmer, it's that JavaScript is incredibly forgiving until it isn't. It will happily let you pass the wrong types of data around until something tries to use that data in a way that doesn't work.

In the previous article, we explored TypeScript's fundamental types like string, number, and boolean. You saw how these types can catch bugs before your code runs. But you might be wondering: "I already know how to declare variables and write functions in JavaScript. How different can TypeScript really be?"

The answer becomes clear when you see how TypeScript handles the everyday code you write. The syntax looks familiar, but the safety guarantees are completely different. Consider what happens when we're building ExplainThis's article recommendation system:

function getRecommendations(userId, articleCount) {
  const user = getUserById(userId);
  const preferences = user.preferences || {};

  return findArticles(preferences.categories, articleCount);
}

// JavaScript accepts all of these calls without warning
getRecommendations("user_123", 5); // Works as expected
getRecommendations("user_123", "five"); // Passes wrong type, breaks later
getRecommendations(null, 5); // Crashes with "Cannot read property 'preferences' of null"

JavaScript won't complain about any of these calls. The errors only surface when the code actually runs and tries to use the wrong data. TypeScript changes this by checking your variable declarations and function calls before your code ever runs.

Why Variable Declarations Matter More in TypeScript

In JavaScript, you might declare variables like this without thinking much about it:

let currentUser = getCurrentUser();
let articleCount = 0;
let isPublished = false;

This works fine until getCurrentUser() returns null, or until some other part of your code accidentally assigns a string to articleCount. TypeScript helps by making these potential problems visible immediately.

Here's a mental model that changed everything for me: think of TypeScript variable declarations as contracts with your future self. When you declare a variable in TypeScript, you're not just creating a container for data—you're making a promise about what kind of data will live there, and TypeScript becomes your accountability partner.

Consider what happens in your brain when you write let articleCount = 0. You're thinking "this will hold numbers representing how many articles we have." But six months later, when you're debugging at 2 AM, you might accidentally write articleCount = "loading..." to show a loading state. JavaScript shrugs and lets you do it. TypeScript stops you and says "Hey, remember that promise you made?"

When you declare variables in TypeScript, you're essentially making promises about what kind of data they'll hold. Sometimes TypeScript can figure this out on its own (this is called type inference, and it's like having a really smart assistant who understands your intentions):

let articleTitle = "Understanding TypeScript Variables"; // TypeScript knows: string
let viewCount = 1250; // TypeScript knows: number
let isDraft = true; // TypeScript knows: boolean

Notice something beautiful here: TypeScript looked at your initial values and said "Ah, I see what you're going for." It's like having a pair programming partner who pays attention to context clues.

If you try to break these promises later, TypeScript will catch you:

articleTitle = 42; // Error: Type 'number' is not assignable to type 'string'
viewCount = "one thousand"; // Error: Type 'string' is not assignable to type 'number'

These errors might seem annoying at first, but they're actually TypeScript saying "I'm confused about what you're trying to do here. Can you help me understand?" It's protection against the most common source of JavaScript bugs: accidentally mixing up data types.

But what happens when TypeScript can't figure out what you intend? This is where you need to be explicit, and this is where the real power shows up:

let authorRole: string; // Will be assigned based on user permissions
let maxArticles: number | null = null; // Could be a limit or unlimited
let blogConfig: { siteName: string; postsPerPage: number }; // Complex structure

// Later in your code, when you assign values:
authorRole = "editor"; // TypeScript knows this is safe
maxArticles = 50; // Also safe
blogConfig = { siteName: "ExplainThis", postsPerPage: 10 }; // TypeScript verifies the structure

Here's what's really happening: you're teaching TypeScript about the "shape" of your data. That blogConfig declaration is like showing TypeScript a blueprint and saying "any object assigned to this variable must have exactly these properties with exactly these types." It's not just type checking—it's structural validation.

The number | null syntax (called a union type) is particularly powerful. You're telling TypeScript "this variable will either hold a number or it will be null, but never anything else." This prevents you from accidentally assigning undefined or a string, which would break code that expects to do math with maxArticles or check if it's null.

When to Let TypeScript Infer vs When to Be Explicit

One question that trips up many developers is: "When should I let TypeScript figure out the types, and when should I write them explicitly?" Here's a practical rule that's served me well:

Let TypeScript infer when the intent is obvious from the code. Be explicit when the intent isn't obvious or when you want extra safety.

// Let TypeScript infer - the intent is clear
const siteName = "ExplainThis";
const maxRetries = 3;
const isEnabled = true;

// Be explicit - the intent isn't clear from initialization
let currentTheme: "light" | "dark" | "auto";
let apiResponse: User | null = null;
let processingStatus: "idle" | "loading" | "success" | "error" = "idle";

// Be explicit - you want extra safety for complex data
const userPreferences: {
  theme: string;
  notifications: boolean;
  language: string;
} = getUserPreferences();

The key insight is that type annotations are communication tools—they communicate your intent to future readers of the code (including yourself), and they communicate your assumptions to TypeScript so it can hold you accountable.

This explicit typing becomes especially important when you're working with data from outside your code. Here's where many developers have their first "TypeScript aha moment":

// Without explicit typing - TypeScript can't help at all
let apiResponse = await fetch("/api/user").then((r) => r.json()); // TypeScript sees this as 'any'
console.log(apiResponse.name.toUpperCase()); // No error shown, but crashes if name doesn't exist

// With explicit typing - TypeScript can help with what you expect
let apiResponse: { name: string; email: string } | null = await fetch(
  "/api/user"
).then((r) => r.json());
console.log(apiResponse.name.toUpperCase()); // Error: Object is possibly null - TypeScript reminds you to check!

Here's the insight that clicked for me: TypeScript can't magically know what your API returns, but it can hold you accountable for handling what you claim it returns. When you write that explicit type annotation, you're creating a contract. You're saying "I expect the API to return an object with name and email properties, or null." TypeScript then says "Okay, if that's what you expect, then you need to handle the null case."

This is particularly important when working with external data sources. Your database might return null, your API might be down, your user might not have filled out their profile yet. By being explicit about these possibilities in your types, you force yourself to write defensive code.

Of course, if the API returns completely different data than expected, both versions will still break at runtime. But with explicit typing, TypeScript can at least help you handle the scenarios you do expect, like checking for null values or missing properties. You're trading "it might crash in weird ways" for "it might crash in predictable ways that I've thought about."

Handling Data That Might Not Exist

Real applications constantly deal with data that might not be there. A user might not have set their preferences yet, an API call might return null, or a configuration value might be optional. JavaScript lets you access properties on null or undefined values, which leads to those dreaded "Cannot read property of null" errors.

Here's a mental shift that helps: in JavaScript, null and undefined are invisible until they explode. In TypeScript, they're visible and loud. TypeScript forces you to acknowledge when data might not exist, which feels annoying until you realize it's preventing production crashes.

The secret is that TypeScript isn't just checking for null values—it's training you to think about all the ways your code can fail. When you see | null or | undefined in a type, it's TypeScript saying "Hey, this might not be there. What's your plan?"

let userPreferences: { theme: string; notifications: boolean } | null = null;
let lastPublishedDate: Date | undefined = undefined;

// TypeScript prevents unsafe access
console.log(userPreferences.theme); // Error: Object is possibly null
console.log(lastPublishedDate.getFullYear()); // Error: Object is possibly undefined

// You must check first
if (userPreferences) {
  console.log(userPreferences.theme); // Safe: TypeScript knows it's not null here
}

if (lastPublishedDate) {
  console.log(lastPublishedDate.getFullYear()); // Safe: TypeScript knows it's defined
}

This might feel like extra work at first, but every null check you write is a potential crash you've prevented. You're trading a few extra lines of code for significantly more reliable software.

The beautiful thing is that once you start thinking this way, you begin to see nullable data everywhere in your applications. That user avatar that might not be uploaded yet? That optional configuration setting? That API response that might be empty? TypeScript helps you handle all of these cases consistently, and your users never see those mysterious "Cannot read property of null" errors.

What's really powerful is how this changes your debugging process. Instead of hunting through logs trying to figure out why something is null, you start preventing those null states from causing problems in the first place.

Functions That Actually Tell You What They Expect

Functions are where TypeScript really shines, and where you'll have your biggest "how did I ever live without this?" moments. In JavaScript, functions are essentially black boxes. You pass data in and hope it's the right kind of data. TypeScript turns functions into contracts that specify exactly what they expect and what they'll give you back.

Think about it this way: untyped functions are like vending machines with no labels. You put something in, something comes out, but you're never quite sure what you'll get or what you were supposed to put in. Typed functions are like vending machines with clear labels, pictures, and prices. You know exactly what to expect.

Consider this JavaScript function for calculating reading time:

function calculateReadingTime(wordCount, wordsPerMinute) {
  return Math.ceil(wordCount / wordsPerMinute);
}

This function works fine if you pass it numbers, but JavaScript won't stop you from doing this:

calculateReadingTime("500", 200); // Returns NaN because "500" / 200 doesn't work as expected
calculateReadingTime(500); // Returns NaN because wordsPerMinute is undefined

Both of these calls will silently produce NaN, which then propagates through your application like a virus, turning numbers into "Not a Number" until something tries to display "NaN minutes to read" to your users. The bug is subtle, the symptom is obvious, and the debugging session is painful.

TypeScript prevents these problems by requiring you to specify what types of data the function expects:

function calculateReadingTime(
  wordCount: number,
  wordsPerMinute: number
): number {
  return Math.ceil(wordCount / wordsPerMinute);
}

calculateReadingTime("500", 200); // Error: string is not assignable to number
calculateReadingTime(500); // Error: Expected 2 arguments, but got 1

Now the function is self-documenting. Anyone looking at this code immediately knows it expects two numbers and returns a number. But here's the deeper insight: the types aren't just documentation, they're executable documentation. Regular comments can lie or become outdated, but if you change this function to return a string, TypeScript will immediately tell you about every place in your codebase that expects it to return a number.

The types serve as documentation that can't get out of sync with the implementation, because they are part of the implementation.

You can also be explicit about what the function returns, though TypeScript can often figure this out on its own:

function formatArticleTitle(title: string): string {
  return title.trim().toLowerCase().replace(/\s+/g, "-");
}

function getArticleWordCount(content: string) {
  return content.split(/\s+/).length; // TypeScript infers this returns number
}

Here's a practice that changed my debugging life: be explicit about return types for any function that's more than a few lines long. When TypeScript infers the return type, it's basing that inference on what your implementation currently does. But what if your implementation is wrong?

Being explicit about return types is especially helpful when you're working with functions that have complex logic or multiple return paths. It acts as a safety net that catches implementation mistakes:

function getArticleStatus(article: {
  isDraft: boolean;
  publishDate: Date | null;
}): "draft" | "scheduled" | "published" {
  if (article.isDraft) {
    return "draft";
  }

  if (article.publishDate && article.publishDate > new Date()) {
    return "scheduled";
  }

  return "live"; // Error: "live" is not assignable to "draft" | "scheduled" | "published"
}

TypeScript caught that we returned "live" instead of "published", which helps ensure our function behaves consistently with the rest of our system. This is the kind of bug that would be nearly impossible to catch in code review—it looks reasonable, the logic seems right, but it breaks the contract the function promised to uphold.

The return type annotation acts like a specification: "This function will only ever return one of these three specific strings." If your implementation tries to return anything else, TypeScript stops the code from compiling. You're essentially saying "I want to be held accountable for this promise."

When Functions Are Values

In JavaScript, functions are values just like strings or numbers. You can assign them to variables, pass them as arguments, and return them from other functions. TypeScript can type all of these scenarios, and this is where things get really powerful.

// Define what shape a function should have
type ArticleProcessor = (article: { title: string; content: string }) => string;

// Use that type for variables
let currentProcessor: ArticleProcessor;

// Assign a function that matches the type
currentProcessor = (article) => {
  return `${article.title}: ${article.content.substring(0, 100)}...`;
};

Here's the insight that made function types click for me: when you define a function type like ArticleProcessor, you're creating a template for behavior. You're saying "any function that matches this type must take an article with a title and content, and must return a string."

This is particularly useful when you're working with event handlers, callback functions, or functional programming patterns where functions are passed around frequently. Instead of wondering "what does this callback expect?", the type tells you exactly what shape of function you need to provide.

What About Arrow Functions?

Everything we've covered works exactly the same with arrow functions. The choice between regular functions and arrow functions is mostly stylistic, but there's one TypeScript-specific consideration worth knowing:

// Regular function
function calculateEngagement(views: number, likes: number): number {
  return (likes / views) * 100;
}

// Arrow function - equivalent
const calculateEngagement = (views: number, likes: number): number => {
  return (likes / views) * 100;
};

// Short arrow function
const formatPercentage = (value: number): string => `${value.toFixed(1)}%`;

With arrow functions, TypeScript can often infer the return type from the expression, which makes short functions even more concise. But be careful with this—if the inferred type isn't what you expect, you might be creating subtle bugs.

Use whichever style fits your codebase's conventions. TypeScript works the same way with both, and the safety guarantees are identical.

The Bigger Picture: From Hope to Certainty

Variables and functions are the building blocks of any program, and TypeScript makes them more reliable by adding type safety. Instead of hoping that your variables contain the right kind of data and that your functions receive the correct arguments, you get guarantees enforced by the compiler.

But here's what really changes when you start using TypeScript consistently: your relationship with your code becomes fundamentally different. In JavaScript, you're constantly second-guessing yourself. "Did I remember to handle the null case? What happens if someone passes a string instead of a number? Is this object guaranteed to have the property I'm trying to access?"

With TypeScript, these questions get answered at compile time instead of runtime. Your IDE can provide better autocomplete suggestions because it knows what properties are available on your objects. Refactoring becomes less scary because TypeScript can tell you everywhere that needs to be updated when you change a function signature. And most importantly, you catch many bugs before they reach your users.

There's also a psychological benefit that's hard to quantify: you start writing more confident code. When you know TypeScript has your back, you're more willing to refactor, more willing to make changes, and more willing to write complex logic because you trust that type errors will surface immediately rather than in production.

The next time you're writing a function, try adding TypeScript types to the parameters. You might be surprised by how many potential edge cases become obvious once you start thinking about exactly what types of data your function should accept. That moment of clarity—when you realize you need to handle three different input scenarios you hadn't considered—is TypeScript working exactly as intended.

☕️ Support Us
Your support will help us to continue to provide quality content.👉 Buy Me a Coffee