Beyond the basics: 21 TypeScript features you might not know about

Introduction

At Lingo.dev, I write a lot of TypeScript code. I’m definitely not a wizard, but I do try to play with features that go beyond the basic types.

This post describes a number of features (and when you might want to use them) to help you expand your knowledge beyond the absolute fundamentals.

1. Readonly arrays, tuples, and as const assertions

By default, arrays and objects are mutable, and TypeScript widens literal values to their general types. This makes it harder for TypeScript to help you catch bugs and provide accurate autocomplete.

const colors = ["red", "green", "blue"];
// Type: string[] - could be any strings
colors.push("yellow"); // Allowed, might not be what you want

type Color = (typeof colors)[number]; // string (too general!)

Solution

Use as const to make everything readonly and preserve literal types, or use readonly for specific arrays.

const colors = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]
colors.push("yellow"); // ✗ Error: can't modify readonly array

type Color = (typeof colors)[number]; // "red" | "green" | "blue" ✓

// Or for function parameters:
function display(items: readonly string[]) {
  items.push("x"); // ✗ Error: can't modify
  items.forEach(console.log); // ✓ OK: reading is fine
}

When to use it

  • Configuration or constant data that shouldn’t change
  • Preventing accidental mutations
  • Preserving literal types for better type inference
  • Function parameters that shouldn’t be modified

Learn more: TypeScript Docs: ReadonlyArray

2. keyof typeof for object-as-const enums

TypeScript enums have some quirks and generate JavaScript code. Sometimes you just want to define constants in an object and derive types from them.

Solution

Combine as const (to lock in literal values), typeof (to get the object’s type), and keyof (to get the union of keys or values).

// Define your constants as a plain object
const STATUS = {
  PENDING: "pending",
  APPROVED: "approved",
  REJECTED: "rejected",
} as const; // Lock in the literal values

// Get a union of the values
type Status = (typeof STATUS)[keyof typeof STATUS];
// "pending" | "approved" | "rejected"

function setStatus(status: Status) {
  // TypeScript validates and autocompletes!
}

setStatus(STATUS.APPROVED); // ✓
setStatus("pending"); // ✓
setStatus("invalid"); // ✗ Error

When to use it

  • Alternative to enums with better JavaScript output
  • Creating const-based configuration with derived types
  • When you want both runtime values and compile-time types

Learn more: TypeScript Docs: typeof types

3. Labeled tuple elements

Tuples like [number, number, boolean] work, but it’s not obvious what each position means. Is it [width, height, visible] or [x, y, enabled]?

Solution

Give tuple positions meaningful names that show up in your editor’s autocomplete and error messages.

// Before: unclear what each number means
type Range = [number, number, boolean?];

// After: self-documenting
type Range = [start: number, end: number, inclusive?: boolean];

function createRange([start, end, inclusive = false]: Range) {
  // Your editor will show you the parameter names!
  return { start, end, inclusive };
}

createRange([1, 10, true]); // Clear what each argument means

When to use it

  • Function parameters that are tuple-based
  • Return values with multiple related pieces of data
  • Any tuple where the meaning of positions isn’t obvious

Learn more: TypeScript Docs: tuple types

4. Indexed access and element type extraction

You have a complex type and want to refer to just one property’s type, or extract what’s inside an array, without repeating yourself.

Solution

Use bracket notation (Type["property"]) to access property types, and [number] to get array element types.

type User = {
  id: number;
  profile: {
    name: string;
    emails: string[];
  };
};

// Access nested property types
type ProfileType = User["profile"]; // { name: string; emails: string[] }
type NameType = User["profile"]["name"]; // string

// Extract array element type
type Email = User["profile"]["emails"][number]; // string

When to use it

  • Deriving types from existing types (DRY principle)
  • Extracting array/tuple element types
  • Working with nested structures without redefining types

Learn more: TypeScript Docs: indexed access types

5. User-defined type guards (arg is T)

You write a function that checks if something is a certain type, but TypeScript doesn’t understand that the check actually narrows the type.

function isPerson(x: unknown) {
  return typeof x === "object" && x !== null && "name" in x;
}

function greet(x: unknown) {
  if (isPerson(x)) {
    x.name; // ✗ Error: TypeScript still thinks x is 'unknown'
  }
}

Solution

Use a type predicate (arg is Type) to tell TypeScript that your function performs a type check.

type Person = { name: string; age: number };

function isPerson(x: unknown): x is Person {
  return (
    typeof x === "object" &&
    x !== null &&
    "name" in x &&
    typeof (x as any).name === "string"
  );
}

function greet(x: unknown) {
  if (isPerson(x)) {
    console.log(x.name); // ✓ TypeScript knows x is Person here!
  }
}

When to use it

  • Validating data from APIs or user input
  • Type-safe validation functions
  • Discriminating between types in a union

Learn more: TypeScript Docs: type predicates

6. Exhaustive checking with never

You have a union type (like different shapes or statuses) and a switch statement. Later, someone adds a new variant to the union but forgets to handle it in the switch. No error is thrown – it just silently doesn’t work.

Solution

Add a default case that assigns the value to a never type. If all cases are handled, the default is unreachable. If a case is missing, TypeScript will error because the value isn’t assignable to never.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    default:
      // If all cases are handled, this is unreachable
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

// Later, someone adds triangle:
// type Shape = ... | { kind: "triangle"; base: number; height: number };
// ✓ TypeScript error in default case: triangle is not assignable to never!

When to use it

  • Switch statements over discriminated unions
  • Ensuring all union variants are handled
  • Catching bugs when types evolve over time

Learn more: TypeScript Docs: exhaustiveness checking

7. Type-only imports and exports (import type/export type)

Sometimes you import types from other modules, but those imports show up in your compiled JavaScript even though they’re only used for type checking. This can cause circular dependencies or bundle bloat.

Solution

Use import type to tell TypeScript: “this is only needed for type checking, erase it completely from the JavaScript.”

// Regular import - might end up in compiled JS
import { User } from "./types";

// Type-only import - guaranteed to be removed from JS
import type { User } from "./types";

// Mixed imports
import { saveUser, type User } from "./api";
//       ^^^^^^^^^  ^^^^^^^^^^^
//       value      type-only

When to use it

  • Preventing circular dependency issues
  • Keeping your JavaScript bundle smaller
  • When using build tools that require explicit type-only imports (isolatedModules)
  • Clarifying intent (this is only for types, not runtime code)

Learn more: TypeScript Docs: importing types

8. Ambient module declarations for non-code assets

You import non-TypeScript files (like images, CSS, or data files), but TypeScript doesn’t know what type they should have.

import logo from "./logo.svg"; // ✗ Error: Cannot find module

Solution

Create ambient module declarations that tell TypeScript how to type these imports.

// In a .d.ts file (like global.d.ts or declarations.d.ts)
declare module "*.svg" {
  const url: string;
  export default url;
}

declare module "*.css" {
  const classes: { [key: string]: string };
  export default classes;
}

// Now these work:
import logo from "./logo.svg"; // logo: string
import styles from "./app.css"; // styles: { [key: string]: string }

When to use it

  • Typing imports of images, fonts, styles
  • JSON or data files not handled by your build tool
  • Any non-TypeScript asset your bundler processes

Learn more: TypeScript Docs: module declaration templates

9. The satisfies operator

Sometimes you want TypeScript to check that an object matches a type, but you also want TypeScript to remember the specific values you used (not just that they’re strings or numbers).

// Without satisfies - loses specific information
const routes: Record<string, string> = {
  home: "/",
  profile: "/users/:id",
};
// routes.profile is just 'string', not the specific "/users/:id"

Solution

satisfies checks your object against a type without changing what TypeScript remembers about it.

const routes = {
  home: "/",
  profile: "/users/:id",
} satisfies Record<string, `/${string}`>; // Must be strings starting with "/"

// routes.profile is still the literal "/users/:id" - exact value preserved!

When to use it

  • Configuration objects where you want both validation AND specific value types
  • When you need autocomplete on exact values, not just the general type

Learn more: TypeScript Docs: satisfies operator

10. Assertion functions (asserts and asserts x is T)

Sometimes you want a function that throws an error if a condition isn’t met. Type guards (above) only work in if statements – they don’t affect the code after the function call.

function assertNotNull(x: unknown) {
  if (x == null) throw new Error("Value is null!");
}

const data: string | null = getValue();
assertNotNull(data);
// TypeScript still thinks data might be null here

Solution

Assertion functions use asserts to tell TypeScript: “if this function returns (doesn’t throw), then the condition is true.”

function assertNotNull<T>(x: T): asserts x is NonNullable<T> {
  if (x == null) throw new Error("Value is null!");
}

const data: string | null = getValue();
assertNotNull(data);
// ✓ TypeScript now knows data is definitely string here!
data.toUpperCase(); // Safe to use

When to use it

  • Validation functions that throw on failure
  • Enforcing runtime invariants
  • Early error checking at function boundaries

Learn more: TypeScript Docs: assertion functions

11. Template literal types for string patterns

Imagine you have event names like "user:login", "user:logout", "post:create", etc. You want TypeScript to autocomplete these and catch typos, but there are too many to list manually.

Solution

Template literal types let you describe string patterns using the same syntax as JavaScript template strings.

// Generate all combinations automatically
type EventName = `${"user" | "post"}:${"create" | "delete"}`;
// Result: "user:create" | "user:delete" | "post:create" | "post:delete"

function trackEvent(event: EventName) {
  // TypeScript will autocomplete and validate the event names!
}

trackEvent("user:create"); // ✓ OK
trackEvent("user:update"); // ✗ Error - not a valid combination

When to use it

  • API routes or event names that follow a pattern
  • CSS class names with prefixes/suffixes
  • Any structured string format (like database table names, file paths)

Learn more: TypeScript Docs: template literal types

12. Distributive conditional types

You want to filter or transform a union type (like string | number | null) by applying logic to each member.

Solution

Conditional types automatically distribute over unions when the checked type is “naked” (not wrapped in another type).

// Remove null and undefined from a union
type NonNullish<T> = T extends null | undefined ? never : T;

// This distributes: checks each member separately
type Clean = NonNullish<string | number | null>;
// string | number (null was filtered out)

// Extract only function types
type FunctionsOnly<T> = T extends (...args: any[]) => any ? T : never;
type Fns = FunctionsOnly<string | ((x: number) => void) | boolean>;
// (x: number) => void

When to use it

  • Filtering union types
  • Building utility types like Exclude, Extract
  • Transforming each member of a union differently

Learn more: TypeScript Docs: distributive conditional types

13. infer to capture types inside conditionals

You need to extract a piece of a complex type (like “what type does this function return?” or “what’s inside this array?”).

Solution

Use infer to create a type variable that captures part of the type you’re examining.

// Extract the return type of a function
type ReturnType<F> = F extends (...args: any[]) => infer R ? R : never;

type MyFunc = (x: number) => string;
type Result = ReturnType<MyFunc>; // string

// Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : never;

type Numbers = ElementType<number[]>; // number
type Mixed = ElementType<(string | boolean)[]>; // string | boolean

When to use it

  • Extracting parameter or return types from functions
  • Getting element types from arrays or tuples
  • Parsing types inside template literals or complex structures

Learn more: TypeScript Docs: inferring within conditional types

14. Mapped type modifiers (+readonly, -?, etc.)

Sometimes you need to take an existing type and make all properties required (remove ?), or make everything mutable (remove readonly). Manually rewriting each property is tedious.

Solution

Mapped types can add (+) or remove (-) the readonly and optional (?) modifiers.

// Remove readonly from all properties
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// Remove optional (?) from all properties
type Required<T> = {
  [K in keyof T]-?: T[K];
};

type Config = Readonly<{ port?: number; host?: string }>;
type EditableConfig = Mutable<Required<Config>>; // { port: number; host: string }

When to use it

  • Creating editable versions of readonly types
  • Making all properties required for validation functions
  • Building utility types that transform property modifiers

Learn more: TypeScript Docs: mapping modifiers

15. Key remapping in mapped types (as)

You want to transform an object type by changing property names (like removing a prefix, or filtering out certain properties), but mapped types normally keep the same keys.

Solution

Use as inside a mapped type to transform keys as you iterate over them.

// Remove private properties (those starting with _)
type RemovePrivate<T> = {
  [K in keyof T as K extends `_${string}` ? never : K]: T[K];
};

type WithPrivate = { name: string; _secret: number };
type Public = RemovePrivate<WithPrivate>; // { name: string }

// Add a prefix to all keys
type Prefixed<T> = {
  [K in keyof T as `app_${string & K}`]: T[K];
};

type Original = { id: number; name: string };
type Result = Prefixed<Original>; // { app_id: number; app_name: string }

When to use it

  • Creating public versions of types by filtering out internal properties
  • Converting between naming conventions (camelCase to snake_case)
  • Filtering object types based on key patterns

Learn more: TypeScript Docs: key remapping in mapped types

16. Const type parameters

When you pass an array to a generic function, TypeScript normally “widens” it to a general array type, losing information about the specific values.

function identity<T>(value: T) {
  return value;
}

const pair = identity([1, 2]); // Type is number[], not [1, 2]

Solution

Add const before the type parameter to tell TypeScript: “keep this as specific as possible.”

function identity<const T>(value: T) {
  return value;
}

const pair = identity([1, 2]); // Type is [1, 2] - exact tuple preserved!

When to use it

  • Functions that should preserve exact array/tuple structures
  • Builder functions where you want to track literal values through transformations

Learn more: TypeScript Docs: const type parameters

17. Variadic tuple types and spreads

How do you type a function that needs to accept different numbers of arguments while keeping track of each argument’s type?

Solution

Variadic tuples let you work with lists of types that can grow or shrink. Think of ... as “spread this list of types here.”

// Type that adds an element to the end of a tuple
type Push<T extends unknown[], U> = [...T, U];

type Result = Push<[string, number], boolean>; // [string, number, boolean]

// Real example: Typing a function wrapper
function logged<Args extends unknown[], Return>(
  fn: (...args: Args) => Return,
): (...args: Args) => Return {
  return (...args) => {
    console.log("Calling with:", args);
    return fn(...args);
  };
}

When to use it

  • Wrapping functions while preserving their exact argument types
  • Creating type-safe function composition utilities
  • Building tuple manipulation types

Learn more: TypeScript Docs: tuple types

18. The this parameter in functions

When a function uses this, TypeScript doesn’t know what type this should have. This causes problems with methods, callbacks, and event handlers.

function setName(name: string) {
  this.name = name; // ✗ Error: 'this' has type 'any'
}

Solution

Add an explicit this parameter (doesn’t count as a real parameter at runtime) to type what this should be.

interface Model {
  name: string;
  setName(this: Model, newName: string): void;
}

const model: Model = {
  name: "Initial",
  setName(this: Model, newName: string) {
    this.name = newName; // ✓ TypeScript knows what 'this' is!
  },
};

model.setName("Updated"); // Works
const fn = model.setName;
fn("Test"); // ✗ Error: 'this' context is wrong

When to use it

  • Methods that rely on this
  • Event handler callbacks
  • Functions designed to be called with .call() or .apply()

Learn more: TypeScript Docs: this parameters

19. unique symbol for nominal-like typing

TypeScript uses “structural typing” – two types with the same structure are considered the same. Sometimes you want types that are structurally identical but logically different (like UserID vs ProductID, both strings).

type UserId = string;
type ProductId = string;

function getUser(id: UserId) {
  /* ... */
}

const productId: ProductId = "prod-123";
getUser(productId); // ✗ We want this to be an error, but it's not!

Solution

Use unique symbol to create a “brand” that makes types incompatible even if their structure is identical.

declare const USER_ID: unique symbol;
type UserId = string & { [USER_ID]: true };

declare const PRODUCT_ID: unique symbol;
type ProductId = string & { [PRODUCT_ID]: true };

function getUser(id: UserId) {
  /* ... */
}

const productId = "prod-123" as ProductId;
getUser(productId); // ✓ Now this IS an error!

When to use it

  • Preventing mixing up IDs of different types (user IDs, order IDs, etc.)
  • Creating “nominal” types that can’t be accidentally substituted
  • Type-safe keys for registries or dependency injection

Learn more: TypeScript Docs: unique symbol

20. Module augmentation and declaration merging

You’re using a third-party library and need to add properties to its types (like adding custom configuration options), but you can’t edit the library’s code.

Solution

Use module augmentation to add to existing interfaces or modules from the outside.

// In your own .d.ts file
declare module "express" {
  // Add to Express's Request interface
  interface Request {
    user?: { id: string; name: string };
  }
}

// Now TypeScript knows about req.user in your Express handlers!

When to use it

  • Extending library types with custom properties
  • Adding types for library features that aren’t fully typed
  • Plugin systems where you’re registering new capabilities

Learn more: TypeScript Docs: module augmentation

21. Constructor signatures and abstract “newable” types

You want to write a function that accepts a class (not an instance) and creates instances of it. How do you type “something that can be constructed with new“?

function createInstance(SomeClass: ???) {
  return new SomeClass();
}

Solution

Use a constructor signature: new (...args: any[]) => T to describe something that can be constructed.

// Type describing a constructor
type Constructor<T = unknown, Args extends unknown[] = any[]> = new (
  ...args: Args
) => T;

function createInstance<T>(Ctor: Constructor<T>): T {
  return new Ctor();
}

class User {
  name = "Unknown";
}

const user = createInstance(User); // user: User ✓

// More complex: factory with specific constructor arguments
function createPair<T>(
  Ctor: Constructor<T, [string, number]>,
  name: string,
  age: number,
): T {
  return new Ctor(name, age);
}

When to use it

  • Dependency injection frameworks
  • Factory functions that create instances
  • Generic code that works with classes as values
  • Testing utilities that mock constructors

Learn more: TypeScript Docs: constructor signatures in interfaces

Leave a Reply