DEV Community

YUVRAJ
YUVRAJ

Posted on

Resolving TS2769: Bridging the Type Gap in Node.js JWT Expiration

Introduction

In modern Node.js environments, including NestJS, security best practices demand that configuration values such as JWT expiration times are managed dynamically via environment variables. However, this practice often leads to a stubborn TypeScript compilation failure known as TS2769, which signifies a fundamental conflict between TypeScript's static type inference and the specific type requirements of the underlying jsonwebtoken library.

This guide provides multiple professional solutions to this common problem, with emphasis on both type safety and runtime validation.

1. The Scenario: Dynamic Expiry Configuration

The issue arises when attempting to pass a string retrieved from a configuration service directly into the expiresIn option during JWT token signing.

1.1 Environment Setup

We define clear, human-readable expiration times in our environment file:

# .env file configuration
JWT_SECRET=YOUR_HARD_TO_GUESS_SECRET_KEY

# Token expiration times must be strings compatible with the 'ms' library
# Valid formats: '15m', '2h', '7d', '1000' (milliseconds as string)
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
Enter fullscreen mode Exit fullscreen mode

1.2 The Failing Implementation

The most logical approach—injecting the ConfigService and retrieving values as strings—fails compilation:

// src/auth/auth.service.ts (The Code That Fails Compilation)
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
  ) {}

  getAccessToken(payload: any) {
    // 🛑 ERROR: TypeScript throws TS2769 here
    const accessToken = this.jwtService.sign(payload, {
      expiresIn: this.configService.get('ACCESS_TOKEN_EXPIRY'),
    });
    return accessToken;
  }
}
Enter fullscreen mode Exit fullscreen mode

2. The Diagnosis: Understanding TS2769

The error message states: "Type 'string' is not assignable to type 'number | string | undefined'" or more specifically references the StringValue type.

Why This Happens

  1. Generic String Type: Methods like ConfigService.get() return a generic JavaScript string type
  2. Specialized Union Type: The jsonwebtoken library (used internally by @nestjs/jwt) requires expiresIn to be either:

    • A number (representing seconds)
    • A StringValue type (a specialized string type matching the ms library format like '1h', '30s', '7d')
    • undefined
  3. Type System Limitation: TypeScript cannot statically guarantee that your dynamic string matches the ms library format, so it conservatively prevents compilation

Understanding StringValue

The StringValue type represents strings that the ms library can parse:

  • Valid examples: '2 days', '1d', '10h', '2.5h', '30s', '1000' (ms as string)
  • Invalid examples: 'tomorrow', 'next week', 'invalid'

3. Professional Solutions

Solution 1: Type Assertion (Quick Fix)

The fastest solution uses explicit type assertion to bridge the type gap:

// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { SignOptions } from 'jsonwebtoken'; // ⭐ Import the type

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
  ) {}

  getTokens(userId: string, email: string) {
    const payload = { sub: userId, email };

    // Retrieve and assert the type
    const accessExpiry = (
      this.configService.get('ACCESS_TOKEN_EXPIRY') || '15m'
    ) as SignOptions['expiresIn']; // 🚀 Type assertion

    const accessToken = this.jwtService.sign(payload, {
      expiresIn: accessExpiry,
    });

    return { accessToken };
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros: Simple, minimal code changes
Cons: Bypasses type safety, no runtime validation

Solution 2: Runtime Validation (Recommended for Production)

A more robust approach includes runtime validation to ensure the environment variable is valid:

// src/auth/auth.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { SignOptions } from 'jsonwebtoken';
import ms from 'ms';

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
  ) {}

  private validateExpiry(expiry: string): string {
    try {
      // This will throw if the format is invalid
      ms(expiry);
      return expiry;
    } catch (error) {
      throw new InternalServerErrorException(
        `Invalid JWT expiry format: ${expiry}. Use formats like '15m', '7d', '2h'`
      );
    }
  }

  getTokens(userId: string, email: string) {
    const payload = { sub: userId, email };

    const accessExpiryRaw = this.configService.get('ACCESS_TOKEN_EXPIRY') || '15m';
    const refreshExpiryRaw = this.configService.get('REFRESH_TOKEN_EXPIRY') || '7d';

    // Validate before using
    const accessExpiry = this.validateExpiry(accessExpiryRaw) as SignOptions['expiresIn'];
    const refreshExpiry = this.validateExpiry(refreshExpiryRaw) as SignOptions['expiresIn'];

    const accessToken = this.jwtService.sign(payload, {
      expiresIn: accessExpiry,
    });

    const refreshToken = this.jwtService.sign(payload, {
      expiresIn: refreshExpiry,
    });

    return { accessToken, refreshToken };
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros: Catches configuration errors early, fails safely
Cons: Slightly more code

Solution 3: Centralized Configuration with Type Safety

For larger applications, create a dedicated configuration service:

// src/config/jwt.config.ts
import { registerAs } from '@nestjs/config';
import { SignOptions } from 'jsonwebtoken';
import ms from 'ms';

function validateExpiry(expiry: string): string {
  try {
    ms(expiry);
    return expiry;
  } catch {
    throw new Error(`Invalid JWT expiry: ${expiry}`);
  }
}

export default registerAs('jwt', () => {
  const accessExpiry = process.env.ACCESS_TOKEN_EXPIRY || '15m';
  const refreshExpiry = process.env.REFRESH_TOKEN_EXPIRY || '7d';

  return {
    secret: process.env.JWT_SECRET,
    accessExpiry: validateExpiry(accessExpiry) as SignOptions['expiresIn'],
    refreshExpiry: validateExpiry(refreshExpiry) as SignOptions['expiresIn'],
  };
});

// Usage in service
@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    @Inject(jwtConfig.KEY) private jwt: ConfigType<typeof jwtConfig>,
  ) {}

  getTokens(userId: string, email: string) {
    const payload = { sub: userId, email };

    const accessToken = this.jwtService.sign(payload, {
      expiresIn: this.jwt.accessExpiry, // ✅ Type-safe and validated
    });

    return { accessToken };
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros: Centralized validation, fails at startup (not runtime), excellent type safety
Cons: More initial setup

4. Alternative Import Paths

In NestJS projects, you can import SignOptions from multiple locations:

// Option 1: Direct from jsonwebtoken (most common)
import { SignOptions } from 'jsonwebtoken';

// Option 2: From @nestjs/jwt (if re-exported in your version)
import { JwtSignOptions } from '@nestjs/jwt';
Enter fullscreen mode Exit fullscreen mode

Both approaches work, but importing from jsonwebtoken directly is more explicit and version-agnostic.

5. Testing Your Configuration

Always validate your JWT configuration during application bootstrap:

// src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Test JWT configuration on startup
  const configService = app.get(ConfigService);
  const accessExpiry = configService.get('ACCESS_TOKEN_EXPIRY');

  console.log(`✅ JWT Access Token Expiry: ${accessExpiry}`);

  await app.listen(3000);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The TS2769 error when configuring JWT expiration times is a common challenge when integrating dynamic configuration with strict TypeScript type definitions. The solutions presented here range from quick type assertions to comprehensive runtime validation:

  • Use Solution 1 for rapid prototyping or simple applications
  • Use Solution 2 for production applications requiring runtime safety
  • Use Solution 3 for enterprise applications with complex configuration needs

All approaches use type assertion (as SignOptions['expiresIn']) to satisfy TypeScript's compiler while maintaining the flexibility of environment-based configuration. The key difference lies in when and how you validate the configuration values.

Key Takeaways

  1. Type assertions bypass compile-time safety for runtime flexibility
  2. Always validate environment variables that affect security (like JWT expiration)
  3. Fail early (at startup) rather than during request handling
  4. Use the ms library's validation to ensure format correctness
  5. Document valid formats in your .env.example files

By combining type assertions with proper runtime validation, you maintain both TypeScript's type safety benefits and the flexibility required for production deployments.

Top comments (0)