Skip to main content

TypeScript as const Guide

TypeScript widens literal values by default. Write const status = "pending" and TypeScript infers the type as string, not the literal "pending". This usually works fine until you need to pass that value to a function expecting specific string literals. Suddenly you're wrestling with type errors even though your code is correct.

The as const assertion locks this down. Instead of widening "pending" to string, TypeScript preserves the exact literal type. This small change gives you powerful type patterns: union types extracted from arrays, deeply readonly configuration objects, and compile-time guarantees that your constants actually stay constant.

Introduction to as const

When you add as const to a value, TypeScript stops widening its type. Instead of inferring general types like string or number, it preserves the exact literal value:

const status = "completed" as const;
// status is of type "completed", not string

This type specificity offers several benefits:

  • It prevents accidental reassignment to other values
  • It enables more precise function parameter typing
  • It helps TypeScript catch errors that might otherwise go unnoticed

When working with utility types and type assertion, as const becomes an essential tool in your TypeScript toolbox.

Using as const for Literal Type Inference

TypeScript normally widens primitive values to their base types. A string literal becomes string, and a numeric literal becomes number. The as const assertion prevents this widening:

// Without as const
const color = "red";
// Type is widened to string

// With as const
const color = "red" as const;
// Type is preserved as literal "red"

This precise typing matters when working with functions that expect specific string literals:

function setTheme(theme: "light" | "dark" | "system") {
// Implementation
}

// Without as const - causes error
const userTheme = "light";
setTheme(userTheme); // Error: Argument of type 'string' is not assignable

// With as const - works perfectly
const userTheme = "light" as const;
setTheme(userTheme); // Works correctly

By using as const, you're telling TypeScript to treat the value as a specific literal type rather than a general one. This helps catch type errors early and enables better autocompletion in your IDE.

This technique pairs well with keyof when you need to reference specific property names in complex objects. When building end-to-end typed applications, check out Convex's approach to TypeScript.

Applying as const for Stricter Type Safety

Using as const with arrays creates readonly tuple types with specific literal elements:

// Without as const
const colors = ["red", "green", "blue"];
// Type: string[]

// With as const
const colors = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]

This creates two significant type safety improvements:

  1. The array becomes readonly, preventing mutations like push() or pop()
  2. The exact elements are preserved as literal types

This approach is ideal for defining sets of allowed values:

const VALID_STATUSES = ["pending", "processing", "completed"] as const;
type Status = typeof VALID_STATUSES[number];
// Status type is: "pending" | "processing" | "completed"

// Now you can use this type in your functions
function updateStatus(newStatus: Status) {
// Implementation
}

// Error: Argument of type '"rejected"' is not assignable to parameter of type 'Status'
updateStatus("rejected");

When building complex applications, this pattern keeps your data validation and types consistent across your codebase. It also works well with type narrowing techniques and tuple types.

Converting Objects to Readonly Types with as const

You can use as const to convert an object to a readonly type:

// Without as const
const config = { mode: "dark", fontSize: 16 };
// Type: { mode: string; fontSize: number; }

// With as const
const config = { mode: "dark", fontSize: 16 } as const;
// Type: { readonly mode: "dark"; readonly fontSize: 16; }

This immutability prevents accidental property changes:

const settings = {
theme: "dark",
notifications: {
email: true,
push: false
}
} as const;

// Error: Cannot assign to 'theme' because it is a read-only property
settings.theme = "light";

// Error: Cannot assign to 'email' because it is a read-only property
settings.notifications.email = false;

You can also use type inference with as const objects:

const API_ENDPOINTS = {
users: "/api/users",
posts: "/api/posts",
comments: "/api/comments"
} as const;

// Extract the endpoint values as a union type
type Endpoint = typeof API_ENDPOINTS[keyof typeof API_ENDPOINTS];
// Type: "/api/users" | "/api/posts" | "/api/comments"

This pattern works well for creating type-safe configurations in backend APIs with Convex and frontend applications. You can also combine this with readonly for additional type safety.

Preventing Accidental Type Widening with as const

Type widening occurs when TypeScript broadens a literal type to its base type. When passing literals to functions or using them in conditional logic, TypeScript might widen their types:

// Function that takes a callback
function fetchData(onSuccess: (status: string) => void) {
// Implementation
}

// The specific string literal type is lost when passed as a parameter
fetchData((status) => {
// Here, status is typed as 'string', not as specific values
if (status === "success") {
// ...
}
});

You can use as const to prevent this widening:

const STATUSES = {
SUCCESS: "success",
ERROR: "error",
LOADING: "loading"
} as const;

function handleStatus(status: typeof STATUSES[keyof typeof STATUSES]) {
// status is typed as: "success" | "error" | "loading"
}

// No type widening occurs, the literal types are preserved
handleStatus(STATUSES.SUCCESS);

This technique is crucial when performing type narrowing in conditional blocks and switch statements. By preserving the literal types, you ensure TypeScript can accurately track the possible values throughout your code. For best practices with TypeScript in your projects, check out Convex's TypeScript guidelines.

Extracting Types from Constant Values

One powerful pattern with as const is extracting union types directly from your constant definitions. This creates a single source of truth and eliminates the need to maintain separate type definitions:

// Define roles as constants
const ROLES = ["admin", "user", "guest"] as const;
type Role = typeof ROLES[number];
// Role type is: "admin" | "user" | "guest"

// Define permission levels with associated values
const PERMISSIONS = {
READ: 1,
WRITE: 2,
DELETE: 4,
ADMIN: 8
} as const;
type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
// Permission type is: 1 | 2 | 4 | 8

You can extract specific properties from complex constant objects:

const CONFIG_OPTIONS = [
{ id: "primary", label: "Primary", color: "#0066cc" },
{ id: "secondary", label: "Secondary", color: "#ff9900" },
{ id: "tertiary", label: "Tertiary", color: "#00cc66" }
] as const;

// Extract just the ID values as a union type
type ConfigId = typeof CONFIG_OPTIONS[number]["id"];
// Type: "primary" | "secondary" | "tertiary"

// Use it in a type-safe function
function getConfigById(id: ConfigId) {
return CONFIG_OPTIONS.find(opt => opt.id === id);
}

getConfigById("primary"); // Works
getConfigById("invalid"); // Error: Argument of type '"invalid"' is not assignable

This pattern integrates well with Convex database schemas and validation where you need to enforce specific allowed values. It's also useful when working with enum alternatives.

Using as const with Function Return Types

When functions return arrays, TypeScript infers them as loose array types rather than specific tuples. This can cause type safety issues when you expect fixed positions:

// Without as const - returns number[]
function getCoordinates() {
return [10, 20];
}

const [x, y] = getCoordinates();
// x and y are both typed as number, but no guarantee of array length

// With as const - returns readonly [10, 20]
function getCoordinates() {
return [10, 20] as const;
}

const [x, y] = getCoordinates();
// x is 10, y is 20 (literal types)
// TypeScript knows there are exactly 2 elements

This becomes more powerful with dynamic values:

function createPoint(x: number, y: number) {
return [x, y] as const;
// Returns readonly [number, number] tuple instead of number[]
}

function processPoint(point: readonly [number, number]) {
const [x, y] = point;
// TypeScript knows point has exactly 2 elements
console.log(`Processing point at (${x}, ${y})`);
}

const point = createPoint(5, 10);
processPoint(point); // Type-safe

This pattern makes your functions return precise tuple types instead of loose arrays, catching bugs where you might access non-existent array indices.

as const vs readonly vs const: Understanding the Differences

Developers often confuse these three keywords because they all relate to immutability, but they serve different purposes:

// const - prevents variable reassignment (JavaScript feature)
const user = { name: "Alice" };
user = { name: "Bob" }; // Error: Cannot reassign const variable
user.name = "Bob"; // OK - properties are mutable

// readonly - makes object properties immutable (TypeScript feature)
type User = {
readonly name: string;
};
const user: User = { name: "Alice" };
user.name = "Bob"; // Error: Cannot assign to 'name' because it is read-only

// as const - deep readonly + literal type inference (TypeScript feature)
const user = { name: "Alice" } as const;
user.name = "Bob"; // Error: Cannot assign to 'name' because it is read-only
// Also: user.name has type "Alice", not string

Here's when to use each:

FeatureScopeRuntime/CompileDeep ImmutabilityLiteral Types
constVariable bindingRuntimeNoNo
readonlyObject propertiesCompile-timeNo (manual)No
as constEntire valueCompile-timeYesYes

Use const when you want to prevent variable reassignment but don't care about property mutations:

const API_URL = "https://api.example.com"; // Can't reassign, value is immutable

Use readonly when you want to make specific properties immutable in type definitions:

interface Config {
readonly apiKey: string;
timeout: number; // This one can change
}

Use as const when you need deep immutability and want to preserve literal types:

const routes = {
home: "/",
about: "/about",
contact: "/contact"
} as const;
// Everything readonly + all values are literal types

Think of const as protecting the label (the variable itself), readonly as protecting specific contents (individual properties), and as const as putting everything in a sealed, labeled box.

When working with Convex database relationships, using as const with configuration objects helps maintain consistency throughout your codebase. This works particularly well with Record<K, T> types for mapping configurations.

Keeping TypeScript Literals Immutable with as const

The as const assertion makes values deeply immutable at the type level:

// Simple string literal
const API_KEY = "abc123" as const;
// Error: Cannot assign to 'API_KEY' because it is a constant
API_KEY = "xyz789";

// Object with nested properties
const APP_CONFIG = {
version: "1.0.0",
features: {
darkMode: true,
notifications: {
email: true,
push: true
}
}
} as const;

// Error: Cannot assign to 'version' because it is a read-only property
APP_CONFIG.version = "1.0.1";

// Error: Cannot assign to 'push' because it is a read-only property
APP_CONFIG.features.notifications.push = false;

This immutability extends to arrays:

const ALLOWED_ORIGINS = ["https://example.com", "https://api.example.com"] as const;

// Error: Property 'push' does not exist on type 'readonly ["https://example.com", "https://api.example.com"]'
ALLOWED_ORIGINS.push("https://dev.example.com");

// Error: Cannot assign to '0' because it is a read-only property
ALLOWED_ORIGINS[0] = "https://new-example.com";

When working with Convex server functions, this immutability keeps configuration values consistent throughout your application's lifecycle. You can also combine this with interface definitions for more complex type structures.

Common Challenges and Solutions

When working with TypeScript, developers encounter several recurring challenges that as const can help solve. Here are some practical solutions to these common issues.

Working with Discriminated Unions

Discriminated unions benefit greatly from as const when defining type discriminators:

const EVENT_TYPES = {
CLICK: "click",
SUBMIT: "submit",
LOAD: "load"
} as const;

type EventType = typeof EVENT_TYPES[keyof typeof EVENT_TYPES];

// Create discriminated union
type ClickEvent = {
type: typeof EVENT_TYPES.CLICK;
element: string;
x: number;
y: number;
};

type SubmitEvent = {
type: typeof EVENT_TYPES.SUBMIT;
formData: Record<string, string>;
};

type LoadEvent = {
type: typeof EVENT_TYPES.LOAD;
duration: number;
};

type AppEvent = ClickEvent | SubmitEvent | LoadEvent;

// Type-safe event handler
function handleEvent(event: AppEvent) {
switch (event.type) {
case EVENT_TYPES.CLICK:
// TypeScript knows event is ClickEvent here
console.log(`Clicked ${event.element} at (${event.x}, ${event.y})`);
break;
case EVENT_TYPES.SUBMIT:
// TypeScript knows event is SubmitEvent here
console.log(`Form submitted with data:`, event.formData);
break;
case EVENT_TYPES.LOAD:
// TypeScript knows event is LoadEvent here
console.log(`Page loaded in ${event.duration}ms`);
break;
}
}

This approach with as const maintains type safety through the entire flow of your application when working with complex filters in Convex queries. It's particularly effective when combined with discriminated union patterns.

Using as const with Generic Functions

When working with generic functions, you might need to preserve literal types:

function createState<T>(initialValue: T) {
let value = initialValue;

return {
get: () => value,
set: (newValue: T) => { value = newValue; }
};
}

// Without as const
const counter = createState({ count: 0 });
// Type is { get: () => { count: number }; set: (newValue: { count: number }) => void }

// With as const
const counter = createState({ count: 0 } as const);
// Type is { get: () => { readonly count: 0 }; set: (newValue: { readonly count: 0 }) => void }

// This works with as const because the exact type is preserved
counter.set({ count: 0 });

// This fails with as const, protecting against invalid updates
counter.set({ count: "zero" }); // Error: Type 'string' is not assignable to type 'number'

This pattern is useful when building state management systems with Convex's useState-less approach and when working with generics in your application.

When NOT to Use as const

While as const is powerful, it's not always the right choice. Here are scenarios where you should avoid it or use alternatives:

When You Need Runtime Immutability

as const only provides compile-time type safety. It doesn't prevent mutations at runtime:

const config = { apiKey: "secret" } as const;

// TypeScript error, but...
// @ts-ignore
config.apiKey = "hacked"; // This actually works at runtime

// If you need runtime protection, use Object.freeze()
const config = Object.freeze({ apiKey: "secret" });
config.apiKey = "hacked"; // Runtime error in strict mode

Solution: For runtime immutability, combine both:

const config = Object.freeze({ apiKey: "secret" } as const);
// Type-level AND runtime immutability

When Working with Mutable Data Structures

Don't use as const when you genuinely need to mutate values:

// Bad - you can't modify this later
const userPermissions = ["read", "write"] as const;
userPermissions.push("delete"); // Error: Property 'push' does not exist

// Good - keep it mutable when needed
const userPermissions = ["read", "write"];
userPermissions.push("delete"); // Works fine

When Literal Types Are Too Restrictive

Sometimes you need flexibility that as const prevents:

// Too restrictive
function updateUser(id: number, changes: { name: string; age: number } as const) {
// Problem: changes is now readonly, can't be spread or modified
}

// Better - accept the broader type
function updateUser(id: number, changes: { name: string; age: number }) {
// Can work with changes normally
}

With Simple Primitives Using const

For basic primitives, regular const is simpler and provides runtime safety:

// Unnecessarily complex
const MAX_RETRIES = 3 as const;

// Simpler and just as effective
const MAX_RETRIES = 3;

Rule of thumb: Use as const when you need to extract types, create readonly structures, or prevent type widening. Stick with regular const for simple values where type widening isn't an issue.

Runtime vs Compile-Time: as const and Object.freeze()

A common source of confusion is thinking as const makes objects immutable at runtime. It doesn't. Let's clarify the difference:

const settings = { theme: "dark" } as const;

// TypeScript prevents this at compile-time
settings.theme = "light"; // Error during development

// But this works at runtime with @ts-ignore
// @ts-ignore
settings.theme = "light"; // No runtime error!
console.log(settings.theme); // "light"

Object.freeze() provides actual runtime immutability:

const settings = Object.freeze({ theme: "dark" });

settings.theme = "light"; // Fails silently in normal mode, throws in strict mode
console.log(settings.theme); // Still "dark"

Best practice: Combine them for complete protection:

const settings = Object.freeze({ theme: "dark" } as const);
// TypeScript enforces immutability during development
// Object.freeze enforces it at runtime

When using the typeof operator with frozen objects, you get both type-level and runtime guarantees.

Final Thoughts on TypeScript as const

The as const assertion enhances type safety by preserving literal types and enforcing immutability at the type level. Use it when you need precise type checking, want to prevent accidental type widening, or need to extract union types from constant values. It works especially well for configuration objects, discriminated unions, and creating enum alternatives.

Remember that as const is a compile-time feature. If you need runtime immutability, combine it with Object.freeze(). For simple primitives where type widening isn't a concern, stick with regular const.

For more advanced TypeScript patterns, check out Convex's TypeScript best practices and consider using satisfies for additional type validation with as const objects.