TypeScript Constants

2 minute read

In a perfect world, we'd never use "magic strings", or have to deal with the history of undocumented constants that another developer has introduced to our code base.

TypeScript provides us with a few easy opportunities/patterns that can be used when creating new constants or when dealing with constants in a legacy codebase.

Constant Classes

Creating a class with the sole job of defining constants is a common design pattern in object-oriented programming languages. This feature is also available to us in TypeScript.

Using a Logger as our use case, we can define the concept of a "LogLevel" class. Our implementation might look something like:

export class LogLevel {
  static readonly ALL = new LogLevel('ALL', Number.MIN_SAFE_INTEGER);
  static readonly OFF = new LogLevel('OFF', Number.MAX_SAFE_INTEGER);

  static readonly DEBUG: LogLevel = new LogLevel('DEBUG', 700);
  static readonly INFO: LogLevel = new LogLevel('INFO', 800);
  static readonly WARN: LogLevel = new LogLevel('WARN', 900);
  static readonly ERROR: LogLevel = new LogLevel('ERROR', 1000);

  private constructor(private name: String, private value: number) {}
}

export interface Logger {
  log(logLevel: LogLevel, message: string);
}

Constant String Unions

Constant classes work great when we're dealing with brand new code, but sometimes we may want to document our existing code without enforcing the use of a new Class. Introducing a Class may break the compilation of a legacy codebase that relied on using magic strings.

Suppose we have a User object defined in our client-side project, that we use to create User objects when deserializing JSON responses. We have knowledge of the domain model, and we know that the User will always have a role with a value of guest, basic, or admin. We also construct new users on the client to eventually save to the server, and want to enforce that we pass in one of the expected magic strings.

export class User {
  public constructor(
    public firstName: string,
    public lastName: string,
    public role: string // guest, basic or admin
  ) {}
}

// not a valid role!
const newUser: User = new User('New', 'User', 'superuser');

We'd like the TypeScript compiler to help us in this case (as superficial as it may be), to prevent construction of such users. We can define a string union type to accomplish this:

type Role = 'guest' | 'basic' | 'admin';

export class User {
  public constructor(public firstName: string, public lastName: string, public role: Role) {}
}

const newUser: User = new User('New', 'User', 'superuser');
// compiler error: Argument of type '"superuser"' is not assignable to
// parameter of type 'Role';

This may not eliminate the use of hard-coded values in the application, but it at least gives you some confidence that your TypeScript code isn't introducing any bad values, and it puts you in a position to start a more meaningful code refactor.