Gary Sieling

Writing a Log4J alternative in TypeScript

An interesting exercise to understand TypeScript better is to write a logging utility.

Typically, you want to be able to write code like this:

logger.log(LogLevel.error, "A problem occurred")

For extra credit, it’s nice to be able to also lazily evaluate the strings (prevents some needless concatenation):

logger.log(LogLevel.error, () => "A problem occurred")

TypeScript supports enums, so let’s first define one for the log level:

enum LogLevel {
  info, debug, error, critical, none
}

When I was working through this, I thought I might try the Javascript trick, of storing values on a function (e.g the log level), like so:

function log(level, message) {
  if (log.level >= level) 
    console.log(message);
}

Unfortunately TypeScript doesn’t like this, because “log” isn’t defined as having “level” as a member. You can usually get around this with index notation, like below, but this seems like cheating, and defeats the value of using TypeScript.

function log(level, message) {
  if (log["level"] >= level) 
    console.log(message);
}

Rather, if you want a private log level, you can wrap the whole thing in an extra level of functions. This way you can let people change the level, but still have some amount of control over the variable.

const logger =
  1. I don’t really want to introduce a dependency, so I’ve replicated here what the implementation of “isFunction” is in Underscore.
function isFunction(obj) {
  return !!(obj && obj.constructor && obj.call && obj.apply);
}

Now, if we were writing Javascript, we could call it inside our logging code:

if (log.level >= level) 
  onLog(
    isFunction(message) ? 
    message() : 
    message);

What will actually happen is that TypeScript will complain about the things we’ve done. The “isFunction” call doesn’t specify the type of what it gets (because that is the whole point), and we haven’t told TypeScript that our logger could take a function:

jobs.ts(8,21): error TS7006: 
Parameter 'obj' implicitly has an 'any' type.

jobs.ts(20,17): error TS2349: 
Cannot invoke an expression whose type lacks a call signature.

The first error is easily fixed, although in the long run this may not be a good solution:

function isFunction(obj: any) {
  return !!(obj && obj.constructor && obj.call && obj.apply);
}

To get our logging code to work, we need to make type that represents the message lambda (something that takes no arguments and returns a string). Once we’ve done this, we can exploit another awesome TypeScript feature, which is union types. This lets us declare that the argument is either a string or a specific type of function. This is a pretty common Javascript behavior, so it’s clearly necessary to make TypeScript viable at all- for instance, core Node APIs for file access return a Buffer or String, depending on whether you request an encoding.

type MessageLambda = () => string;
type Message = MessageLambda | string;
log: (level: LogLevel, message: Message ) => {
  ...
}

At this point, we’re still getting TypeScript errors, but we’re getting close (even with these errors, the Javascript output should still work)

jobs.ts(23,17): error TS2349: 
Cannot invoke an expression whose type lacks a call signature.

What is really going on here is that you effectively need to check what the union type actually is, and then handle it appropriately. In Java, this would be a type check and a cast, and in functional languages like Scala, you would use pattern matching.

The TypeScript syntax for this is bizarre, but most resembles Scala pattern matching. . This is referred to as “type guards” – once the code steps into the type guard2, the type of the thing you’re checking becomes more specific, hence how it is similar to pattern matching, although Scala’s pattern matching lets you do a lot of stuff you can’t do here.

if (typeof message === "function") {
  onLog(message())
} else if (typeof message === "string") {
  onLog(message);
}

I think the chief difficulty that TypeScript has here is that the additional type information isn’t retained at runtime, similar to Java’s type erasure. Unlike Java, the TypeScript developers have found ways to make the language useful in spite of the erasure. The compiler seems to be quite fast, so the tradeoffs seems to be a reasonable ones.

So to test this, we can do exactly what we’d expect:

logger.log(LogLevel.error, "error");
logger.log(LogLevel.debug, "debug");
logger.log(LogLevel.debug, () => "lambda");
logger.log(LogLevel.info, "info");
error
debug
lambda
  1. ) => { let currentLevel = LogLevel.debug; return { log: (level: LogLevel, message: string) => { if (level >= currentLevel) { console.log(message); } }, setLevel(level: LogLevel) { currentLevel = level; } })()

It’s probably a good idea to avoid being dependent on console.log, as in a real system we might want to log to a file, network, etc. Log4j lets you control this through configuration. For our case, I’d like to at least take the logging function as a parameter.

We can change the above code to accept a strongly-typed lambda:

let logger = 
   ((onLog: (value: string)=>void ) => {
     ..
   }(console.log)

As I noted above, logging can be a performance impact, so it’s nice not to evaluate the strings you’re logging, if you’re never going to put them anywhere. Consequently, we want to be able to detect whether the input is a function or not.

There are apparently a number of ways to do this – we could do it ourselves, or depend on a faster but puzzling implementation in a library ((http://stackoverflow.com/questions/5999998/how-can-i-check-if-a-javascript-variable-is-function-type []

  • https://www.typescriptlang.org/docs/handbook/advanced-types.html []
  • Exit mobile version