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:
- The array becomes readonly, preventing mutations like
push()orpop() - 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:
| Feature | Scope | Runtime/Compile | Deep Immutability | Literal Types |
|---|---|---|---|---|
const | Variable binding | Runtime | No | No |
readonly | Object properties | Compile-time | No (manual) | No |
as const | Entire value | Compile-time | Yes | Yes |
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.