TypeScript Basic Types

One of TypeScript's main strengths is its rich type system. Understanding the basic types is essential for writing effective TypeScript code. This guide covers the fundamental types available in TypeScript and how to use them in your projects.

Primitive Types

TypeScript includes the same primitive types available in JavaScript, but with added type safety.

Boolean

The most basic data type is the simple true/false value, known as a boolean.

boolean

let isDone: boolean = false;
let isActive: boolean = true;

// Type inference works too - TypeScript knows this is a boolean
let isComplete = false;

Number

As in JavaScript, all numbers in TypeScript are floating point values. These can be decimal, hexadecimal, binary, or octal literals.

number

let decimal: number = 10;
let hex: number = 0xf00d; // hexadecimal
let binary: number = 0b1010; // binary
let octal: number = 0o744; // octal

// Operations
let sum: number = 10 + 5; // 15
let difference: number = 10 - 5; // 5

TypeScript also supports special numeric values like NaN (not a number) and Infinity:

NaN / Infinity

let notANumber: number = NaN;
let infiniteValue: number = Infinity;

String

Text data is represented using the string type. You can use single quotes ('), double quotes ("), or template literals (`) to create string values.

string

let firstName: string = "John";
let lastName: string = "Doe";

// Template literals can span multiple lines and embed expressions
let fullName: string = `${firstName} ${lastName}`;
let greeting: string = `Hello, ${fullName}!
Welcome to TypeScript.`;

Symbol

Symbols are immutable and unique. They were introduced in ECMAScript 2015 and can be used as keys for object properties.

symbol

let sym1: symbol = Symbol();
let sym2: symbol = Symbol("key"); // optional string key

// Each symbol is unique
let areEqual: boolean = sym1 === sym2; // always false

BigInt

BigInt represents whole numbers larger than 2^53 - 1. They are created by appending n to the end of an integer or by calling the BigInt() function.

bigint

// Only available when targeting ES2020 or later
let bigNumber: bigint = 9007199254740991n;
let anotherBigNumber: bigint = BigInt(9007199254740991);

Special Types

null and undefined

TypeScript has two special types, null and undefined, which represent different concepts:

  • null: Explicitly indicates the absence of a value or that a value is intentionally empty. It represents a deliberate "nothing" value and must be assigned.
  • undefined: Represents a value that hasn't been assigned yet or doesn't exist. Variables are automatically undefined when declared but not initialized.

These differences reflect how JavaScript treats these values:

null / undefined

// Variables explicitly set to null
let userContact: string | null = null; // We know there's no contact info
let selectedItem: object | null = null; // No item is selected

// Variables that are undefined
let userName: string; // Declared but not initialized, so it's undefined
console.log(userName); // undefined

function findUser(id: number): object | undefined {
  // Return undefined when no user is found
  if (id < 0) return undefined;
  // ...
}

// The difference in checking
let value1 = null;
let value2 = undefined;

console.log(typeof value1); // "object" (historical JavaScript quirk)
console.log(typeof value2); // "undefined"

By default, both null and undefined are subtypes of all other types, meaning you can assign them to something like number:

subtypes

let n: null = null;
let u: undefined = undefined;

// This is allowed by default (unless strictNullChecks is enabled)
let num: number = null;
let id: number = undefined;

When using the --strictNullChecks flag (recommended), null and undefined are only assignable to unknown, any, and their respective types. This helps avoid many common errors:

--strictNullChecks

// With strictNullChecks
let num: number = null; // Error
let id: number = undefined; // Error
let maybeNum: number | null = null; // OK with union type
let maybeId: number | undefined = undefined; // OK with union type

any

The any type allows you to opt-out of type checking and essentially revert back to JavaScript's dynamic typing:

any

let notSure: any = 4;
notSure = "maybe a string";
notSure = false; // okay, definitely a boolean

// Using any disables type checking
notSure.toFixed(); // No compile error, might fail at runtime
notSure.someNonExistentMethod(); // No error either!

Important: The any type is generally frowned upon in TypeScript and should be avoided whenever possible.

Using any effectively undermines the entire purpose of TypeScript by:

  • Allowing any operation without type checking
  • Bypassing compiler safeguards
  • Eliminating IDE assistance and autocomplete
  • Potentially introducing runtime errors
  • Making code harder to refactor and maintain

Instead of using any, consider these alternatives:

  • Use unknown for values of uncertain types (then narrow them with type guards)
  • Define proper interfaces or types for your data
  • Use union types to represent multiple possible types
  • Use generics for flexible but type-safe code

There are very few legitimate use cases for any:

  • During incremental adoption of TypeScript in an existing JavaScript project
  • Working with third-party libraries without type definitions
  • Rare edge cases where the type system cannot express a particular pattern

Even in these cases, try to limit the scope of any as much as possible.

unknown

The unknown type is TypeScript's type-safe counterpart to any. While both can hold values of any type, they behave very differently in how they let you interact with those values.

unkown

let valueAny: any = 10;
let valueUnknown: unknown = 10;

// With any, you can do anything - no type safety!
valueAny.toFixed(2); // No error
valueAny.someNonExistentMethod(); // No error
valueAny.foo.bar.baz; // No error
let num1: number = valueAny; // No error

// With unknown, you can't do anything without type checking
// valueUnknown.toFixed(2);  // Error: Object is of type 'unknown'
// valueUnknown.length;  // Error: Object is of type 'unknown'
// let num2: number = valueUnknown;  // Error: Type 'unknown' is not assignable to type 'number'

Key differences between any and unknown:

  1. Type safety:
    • any: Completely bypasses type checking
    • unknown: Enforces type checking before operations
  2. Assignment compatibility:
    • any: Can be assigned to any other type
    • unknown: Can only be assigned to any or unknown types
  3. Operations allowed:
    • any: All operations allowed without checks
    • unknown: No operations allowed until type is narrowed
  4. Property access:
    • any: Any property can be accessed
    • unknown: No properties can be accessed without type narrowing

To use an unknown value, you must first narrow its type using type guards:

Use of unknown

let value: unknown = "Hello, TypeScript!";

// Using type guards to narrow the type
if (typeof value === "string") {
  console.log(value.toUpperCase()); // OK: now treated as string
}

// Alternative using type assertion (use with caution)
console.log((value as string).toUpperCase());

// Using instanceof for objects
let someDate: unknown = new Date();
if (someDate instanceof Date) {
  console.log(someDate.toISOString()); // OK: now treated as Date
}

unknown is the recommended approach when you need to represent a value whose type you don't know yet, as it forces you to perform proper type checking before performing operations, preventing runtime errors.

void

The void type represents the absence of a value, commonly used as the return type of functions that don't return a value.

void

function logMessage(message: string): void {
  console.log(message);
  // No return statement or returns undefined
}

// Variable of type void can only be assigned undefined (or null if strictNullChecks is disabled)
let unusable: void = undefined;

void in Arrow Functions and Callbacks

The void type is especially important in TypeScript when working with callback functions, particularly in React component props and event handlers:

void callback functions

// Arrow function that returns void
const logError = (error: Error): void => {
  console.error(error.message);
};

// Callback function type with void return
type ClickHandler = (event: MouseEvent) => void;

// Using void in function type definitions
interface ButtonProps {
  onClick: () => void;
  onHover?: (id: string) => void;
}

Why void is Essential in React Props

In React with TypeScript, the void return type is crucial for event handlers and callback props:

void propping

// Component with callback props
interface SubmitButtonProps {
  // void return type means the parent doesn't expect a return value
  onSubmit: () => void;
  onCancel: () => void;
}

const SubmitButton = ({ onSubmit, onCancel }: SubmitButtonProps) => {
  return (
    <div>
      <button onClick={onSubmit}>Submit</button>
      <button onClick={onCancel}>Cancel</button>
    </div>
  );
};

// Using the component
const Form = () => {
  // These handlers don't need to return anything
  const handleSubmit = () => {
    console.log('Submitted');
    // Implementation details...
  };

  const handleCancel = () => {
    console.log('Cancelled');
    // Implementation details...
  };

  return (
    <SubmitButton
      onSubmit={handleSubmit}
      onCancel={handleCancel}
    />
  );
};

Important Distinction with void

A key point about the void type in TypeScript:

  1. A function declared with a void return type can return any value, but the return value will be ignored:

return value ignored

function warnUser(): void {
  console.log("Warning!");
  return true; // Valid, but the return value is ignored
}

const result = warnUser(); // result is of type void, not boolean
  1. But when a function type has a void return type, it signals intent that the function's return value should not be used, even if it returns something:

return value not being used

type VoidCallback = () => void;

const callback: VoidCallback = () => {
  return "hello"; // Allowed despite void return type
};

// The return is allowed, but you're not supposed to use it
const value = callback(); // value is of type void, not string

This behavior is intentional in TypeScript to enable callbacks to be assigned to void-returning function types even if they happen to return values.

never

The never type represents values that never occur. It's used for functions that never return (because they throw exceptions, have infinite loops, or always terminate the program) and for variables that can never have a value due to type narrowing.

never

// Function that never returns
function error(message: string): never {
  throw new Error(message);
}

// Function that always throws
function fail(): never {
  return error("Something failed");
}

// Function with an infinite loop
function infiniteLoop(): never {
  while (true) {
    // code that never exits
  }
}

How never differs from any and unknown

The never type has important differences from any and unknown:

  1. Relationship in the type hierarchy:
    • any: Top type - can be assigned to and from any other type
    • unknown: Top type - can be assigned from any type, but not to other types without narrowing
    • never: Bottom type - can be assigned to any other type, but no type can be assigned to never
  2. Representing impossibility:
    • any: Represents "could be anything, we don't care about the type"
    • unknown: Represents "could be anything, but we need to check before using it"
    • never: Represents "this cannot/will not happen"
  3. Usage in exhaustive checking:
    • One powerful use of never is in exhaustive checks with discriminated unions:

exhaustive check

type Shape = Circle | Square | Triangle;

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // This line ensures all cases are covered
      // If we add a new shape type but forget to handle it,
      // TypeScript will give us an error here
      const exhaustiveCheck: never = shape;
      return exhaustiveCheck;
  }
}

When used this way, if we later add a new type to the Shape union but forget to handle it in the switch statement, TypeScript will throw an error because the unhandled case would fall through to the default, and the new shape type can't be assigned to never.

  1. Return types:
    • any: A function returning any could return any value
    • unknown: A function returning unknown could return any value, but the caller must verify the type
    • never: A function returning never doesn't return normally at all (throws, infinite loop, etc.)

The never type is especially useful in type systems to represent impossible states and to create more type-safe code by ensuring exhaustive handling of all possible cases.

Complex Types

Arrays

TypeScript provides two ways to define arrays:

Arrays

// Using the type followed by []
let list1: number[] = [1, 2, 3];

// Using the generic Array type
let list2: Array<number> = [1, 2, 3];

// Array of mixed types with a union type
let mixed: (number | string)[] = [1, "two", 3];

// Type inference works with arrays too
let inferred = [1, 2, 3]; // inferred as number[]

Arrays in TypeScript are just like JavaScript arrays with added type safety. You can use all the familiar array methods.

Array methods

let numbers: number[] = [1, 2, 3, 4, 5];

numbers.push(6); // OK
// numbers.push("7");  // Error: Argument of type 'string' is not assignable to parameter of type 'number'

let firstItem = numbers[0]; // Type is number
let moreNumbers = numbers.map((n) => n * 2); // Still number[]

Tuples

Tuples are a special type of array where the type of a fixed number of elements is known. The types don't have to be the same.

Tuples

// Declare a tuple type
let person: [string, number] = ["John", 25];

// Access with correct types
let name: string = person[0];
let age: number = person[1];

// Error when accessing with incorrect type
// let error: string = person[1]; // Error: Type 'number' is not assignable to type 'string'

// Error when adding more elements than defined
// person[2] = "extra"; // Error in strict mode

When accessing an element with a known index, the correct type is retrieved. Tuples are particularly useful when you want to represent a fixed structure, like a key-value pair or a row in a CSV file.

// Representing a CSV row [id, name, active]
type CSVRow = [number, string, boolean];

let rows: CSVRow[] = [
  [1, "Alice", true],
  [2, "Bob", false],
  // [3, "Charlie"] // Error: Type '[number, string]' is not assignable to type 'CSVRow'
];

Recent versions of TypeScript also support named tuple elements for better documentation:

// Named tuple elements
type HttpResponse = [code: number, body: string, headers?: object];

const response: HttpResponse = [200, '{"success": true}', { "Content-Type": "application/json" }];

Enums

Enums allow you to define a set of named constants. They make it easier to document intent or create a set of distinct cases.

enums

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

let move: Direction = Direction.Up;

By default, enums begin numbering their members starting at 0, but you can override this by explicitly setting values:

Override index

enum StatusCode {
  OK = 200,
  Created = 201,
  BadRequest = 400,
  Unauthorized = 401,
  NotFound = 404,
}

function handleResponse(code: StatusCode) {
  switch (code) {
    case StatusCode.OK:
      console.log("Everything is fine");
      break;
    case StatusCode.NotFound:
      console.log("Resource not found");
      break;
    // ...
  }
}

String enums are also supported, which are more readable in debugging:

string enums

enum PrintMedia {
  Newspaper = "NEWSPAPER",
  Newsletter = "NEWSLETTER",
  Magazine = "MAGAZINE",
  Book = "BOOK",
}

let media: PrintMedia = PrintMedia.Magazine;
console.log(media); // "MAGAZINE"

How Enums Improve Code Maintainability

Enums significantly enhance code maintainability by providing meaningful, self-documenting constants and improving type safety. Consider this comparison:

Without enums (using string literals):

Without enums

// Using string literals directly
function processResponse(status: string) {
  if (status === "success") {
    // Process successful response
  } else if (status === "error") {
    // Handle error
  } else if (status === "pending") {
    // Handle pending state
  }
  // What if someone passes "SUCCESS" or "Success" instead?
}

// Calling the function
processResponse("success");
processResponse("SUCCESS"); // No error, but might not work as expected
processResponse("pending");
processResponse("wating"); // Typo, but TypeScript won't catch this!

With enums:

With enums

enum ResponseStatus {
  Success = "success",
  Error = "error",
  Pending = "pending",
}

function processResponse(status: ResponseStatus) {
  switch (status) {
    case ResponseStatus.Success:
      // Process successful response
      break;
    case ResponseStatus.Error:
      // Handle error
      break;
    case ResponseStatus.Pending:
      // Handle pending state
      break;
  }
}

// Calling the function
processResponse(ResponseStatus.Success); // Correct
processResponse(ResponseStatus.Pending); // Correct
// processResponse("success"); // Error: Argument of type '"success"' is not assignable to parameter of type 'ResponseStatus'
// processResponse("wating"); // Error: Argument of type '"wating"' is not assignable to parameter of type 'ResponseStatus'

Key Benefits of Enums for Equality Checks

  1. Type Safety: TypeScript will ensure you only use valid enum values.
  2. Autocomplete: Your IDE will suggest the available enum values.
  3. Centralized Definition: If you need to add, remove, or rename a status, you only change it in one place.
  4. Refactoring Support: If you rename an enum value, TypeScript will help you find all usages.
  5. Self-Documenting: The code clearly indicates what values are valid and expected.
  6. Runtime Structure: Enums exist at runtime as objects, allowing you to iterate over them if needed.

Real-World Example: API Request Status

Real-World example

enum RequestStatus {
  Idle = "idle",
  Loading = "loading",
  Success = "success",
  Error = "error"
}

interface RequestState<T> {
  status: RequestStatus;
  data: T | null;
  error: Error | null;
}

function fetchUserData(userId: string): RequestState<User> {
  // Initial state
  let state: RequestState<User> = {
    status: RequestStatus.Idle,
    data: null,
    error: null
  };

  try {
    state.status = RequestStatus.Loading;
    // Fetch data...

    state.status = RequestStatus.Success;
    state.data = /* fetched user */;

  } catch (error) {
    state.status = RequestStatus.Error;
    state.error = error instanceof Error ? error : new Error(String(error));
  }

  return state;
}

// Using the state
const userState = fetchUserData("123");

if (userState.status === RequestStatus.Error) {
  showErrorMessage(userState.error!.message);
} else if (userState.status === RequestStatus.Success) {
  displayUserProfile(userState.data!);
}

In this example, using an enum for request status makes the code more maintainable, self-documenting, and less prone to typos or inconsistencies compared to using string literals directly.

Objects

Object types can be defined using interfaces or type aliases. Here's an example using inline object type annotation:

objects

// Anonymous object type
let user: { id: number; name: string } = {
  id: 1,
  name: "John",
};

// You can also specify optional properties with ?
let product: { id: number; name: string; description?: string } = {
  id: 101,
  name: "Laptop",
  // description is optional
};

For more complex object types, it's better to define them using interfaces or type aliases, which we'll cover in a later section.

Type Assertions

Sometimes you might have information about the type of a value that TypeScript doesn't know about. Type assertions are a way to tell the compiler "trust me, I know what I'm doing."

Trust me bro

// Using angle bracket syntax
let someValue: any = "this is a string";
let strLength1: number = (<string>someValue).length;

// Using as syntax (preferred, especially in JSX)
let strLength2: number = (someValue as string).length;

Type assertions don't change the runtime behavior of your code; they're purely a compile-time construct. They simply tell the TypeScript compiler to treat a value as a specific type.

// Example when working with DOM elements
const input = document.getElementById("inputField") as HTMLInputElement;
// Now you can access input-specific properties
console.log(input.value);

Type Inference

TypeScript can infer types in many situations, reducing the need for explicit type annotations.

Type Inference

// No need for type annotation, inferred as number
let x = 3;

// Function return type is inferred as number
function add(a: number, b: number) {
  return a + b;
}

// inferred as (a: number, b: number) => number
let addFunction = function (a: number, b: number) {
  return a + b;
};

Type inference works well for simple types and can make your code cleaner. However, explicit type annotations can improve code readability and catch errors earlier.

Union Types

Union types allow you to express a value that can be one of several types.

Union Types

// Can be either number or string
let id: number | string;
id = 101; // OK
id = "202"; // OK
// id = true; // Error: Type 'boolean' is not assignable to type 'number | string'

// Using union with arrays
let mixed: (number | string)[] = [1, "two", 3, "four"];

// Function that accepts multiple types
function printId(id: number | string) {
  console.log(`ID: ${id}`);

  // Type narrowing
  if (typeof id === "string") {
    console.log(id.toUpperCase());
  } else {
    console.log(id.toFixed(0));
  }
}

Literal Types

TypeScript also allows defining types as specific literal values, not just general types like string or number.

Literal Types

// String literal type
type Direction = "North" | "South" | "East" | "West";
let direction: Direction = "North"; // OK
// let invalid: Direction = "Northwest"; // Error

// Numeric literal type
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
let roll: DiceRoll = 3; // OK
// let invalid: DiceRoll = 7; // Error

// Boolean literal type (less common)
type Bool = true;
let isTrue: Bool = true; // OK
// let isFalse: Bool = false; // Error

Literal types are particularly useful when combined with union types to define a specific set of allowed values.

// Function that only accepts specific string values
function setAlignment(alignment: "left" | "center" | "right") {
  // ...
}

setAlignment("left"); // OK
// setAlignment("top"); // Error

Type Aliases

Type aliases create a new name for a type. They don't create a new type; they create a new name to refer to that type.

Type Aliases

type UserID = number | string;
type Point = { x: number; y: number };

// Using type aliases
let userId: UserID = 123;
let coordinates: Point = { x: 10, y: 20 };

// Type aliases can be more complex
type Result<T> = { success: true; value: T } | { success: false; error: string };

function getResult(): Result<number> {
  // ...
  if (Math.random() > 0.5) {
    return { success: true, value: 42 };
  } else {
    return { success: false, error: "Something went wrong" };
  }
}

Intersection Types

An intersection type combines multiple types into one. This allows you to add together existing types to get a single type that has all the features you need.

Intersection Types

type Employee = {
  id: number;
  name: string;
};

type Manager = {
  department: string;
  level: number;
};

// Combine Employee and Manager types
type ManagerWithEmployeeInfo = Employee & Manager;

let manager: ManagerWithEmployeeInfo = {
  id: 123,
  name: "John Smith",
  department: "IT",
  level: 2,
};

Type Guards and Type Narrowing

TypeScript uses control flow analysis to narrow types within conditional blocks. This is called type narrowing.

Type Guards

function padLeft(value: string, padding: string | number) {
  // Type narrowing with typeof
  if (typeof padding === "number") {
    // In this branch, padding is known to be a number
    return " ".repeat(padding) + value;
  }
  // In this branch, padding is known to be a string
  return padding + value;
}

// Type guard using instanceof
function processValue(value: string | Date) {
  if (value instanceof Date) {
    // Here value is known to be Date
    return value.toISOString();
  } else {
    // Here value is known to be string
    return value.toUpperCase();
  }
}

You can also create custom type guards using type predicates:

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

// Type predicate
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function move(pet: Fish | Bird) {
  if (isFish(pet)) {
    // pet is now known to be Fish
    pet.swim();
  } else {
    // pet is now known to be Bird
    pet.fly();
  }
}

Exercises

Exercise 1: Type Annotations

Create variables with proper type annotations for the following values:

  1. A user's name
  2. A user's age
  3. Whether the user is active or not
  4. A list of the user's hobbies
  5. The user's mixed-type information tuple: [id, name, active]
  6. A function that takes a user's name and returns a greeting message
  7. A variable that could be either a number or null

Exercise 2: Working with Object Types

Define a type for a Product with the following properties:

  • id (number)
  • name (string)
  • price (number)
  • category (string)
  • inStock (boolean)
  • tags (array of strings, optional)

Then create an array of products and write a function that filters products by category.

Exercise 3: Type Guards and Union Types

Create a function that processes different kinds of data:

  • If given a number, double it and return it
  • If given a string, return its length
  • If given a boolean, return its negation
  • If given an array of numbers, return the sum of all elements
  • If given anything else, throw an error with message "Unsupported data type"

Summary

TypeScript's type system provides a way to describe the shape of JavaScript objects that are passing through your code. Understanding these basic types is crucial for writing type-safe TypeScript code. Key points to remember:

  1. TypeScript includes all JavaScript primitive types like boolean, number, and string
  2. Special types like any, unknown, void, and never serve specific purposes
  3. Complex types include arrays, tuples, enums, and objects
  4. Union and intersection types allow composing types in flexible ways
  5. Type guards help narrow types for safer operations
  6. Type inference can reduce the need for explicit annotations

With these fundamentals, you can now build more complex type definitions and type-safe applications.