Introducing Type Aliases

An immediately useful consequence of type aliases is that common types can be given aliases that more clearly communicate intent:

typealias UserName = String typealias PasswordHash = String type alias Price = BigDecimal

This can save us having to create wrapper classes like:

data class UserName(name: String)

However, we cannot use type aliases to constrain parameters: a function accepting a PasswordHash parameter will accept any String value for that parameter. So we can assign freely, but also incorrectly:

val userName1: UserName = "user1" val password1: PasswordHash = hash("p4ssw0rd") val userName2: UserName = password1 val password2: PasswordHash = userName1

Type aliases at this level are like a sort of colour-coding on types. They’re invisible to the underlying type system, which sees things only in black-and-white, so they’re neither checked by the compiler nor visible at run-time using reflection. However, they’re very convenient for the programmer. In particular, they enable us to express the domain of some part of a program in a very concise way:

typealias UserName = String typealias Password = String typealias PasswordHash = String typealias HashFunction = (Password) -> PasswordHash data class Credentials(val username: UserName, val passwordHash: PasswordHash)

Reading these type declarations, we know the fundamental types and type transformations that the part of the program that uses them is going to be about.

Note the data class at the end. If Kotlin supported a tuple construct, we might be able to write something like:

typealias Credentials = (UserName, PasswordHash)

However, tuples were deliberately removed from Kotlin, in favour of data classes which provide explicit property names (rather than relying on positional indexing for properties). We could define Credentials like this:

typealias Credentials = Pair

but unless we wanted to use the semantics of Pair when handling Credentials (i.e. the a to b infix operator for constructing Pair s, and the interaction between Pair and Map ), this would sacrifice the clarity of having properties named “username” and “passwordHash” (rather than “first” and “second”) to little advantage.

This highlights another important feature of type aliases: the alias has identical semantics to the aliased type. A Password behaves just like a String , and can be used anywhere a String can be used – and vice versa. A type and its alias are isomorphic.

Adding extension functions

In the example above, there is a relationship between Username , Password , HashFunction and Credentials , namely that a HashFunction can be used to make Credentials out of a Username and a Password :

fun makeCredentials(hashFunction: HashFunction, username: Username, password: Password): Credentials = Credentials(username, hashFunction(password))

Extension functions enable us to associate this function with the HashFunction type itself, so that anywhere we have a HashFunction (or a function from Password to PasswordHash ) we can call this function as a method on it:

fun HashFunction.makeCredentials(username: Username, password: Password): Credentials = Credentials(username, hashFunction(password)) val md5: HashFunction = { password -> // hash function implementation } val credentials = md5.makeCredentials(username, password)

This is a powerful feature, but it has some pitfalls.Because Password and PasswordHash are both aliases for String , we have in fact added this extension function to every function with the signature (String) -> String : we are saying that every such function can be treated as a hash function for the purposes of generating credentials. There might be a legitimate worry here about namespace pollution – how is the programmer bringing up the autocomplete on String::intern to interpret the fact that this now has a makeCredentials function attached to it?

However, we can mitigate this by scoping the extension function very tightly to the context in which it is actually used – within the specific package, class or object that deals with credential handling.

There is an echo here of Scala’s implicit conversions, which similarly enable common types (and type-classes) to be ornamented with new features in an ad hoc fashion; and there are some of the same pitfalls, too. If the import which brings the makeCredentials function into scope goes missing, there is no obvious way to find it based on the literal type of the receiver. If, however, we have declared that type to be HashFunction rather than just (String) -> String , then by keeping the extension function definition close to the type alias declaration we can at least point the maintenance programmer (likely ourselves in six months’ time) in the right direction.

Compare the more traditional approach, based on inheritance:

interface HashFunction: (Password) -> PasswordHash { fun makeCredentials(username: Username, password: Password): Credentials = Credentials(username, hashFunction(password)) }

The difficulty here is that we cannot then say:

val md5: HashFunction = { password -> // implementation goes here }

but must settle for the comparatively awkward:

val md5 = object : HashFunction() { override fun invoke(password: Password): PasswordHash = // implementation goes here }

What we lose is the naturalness of being able to implement a hash function simply as a function – it puts a layer of “object bureaucracy” between us and the Kotlin type system.

Higher-order functions with type aliases

Let’s drop the extension function, in this case, and try an alternative approach:

typealias CredentialsBuilder = (Username, Password) -> Credentials fun hashingCredentialsBuilder(hashFunction: HashFunction): CredentialsBuilder = { username, password -> Credentials(username, hashFunction(password)) } val md5Credentials = hashingCredentialsBuilder { password -> // implementation goes here } val credentials = md5Credentials(username, password)

The function hashingCredentialsBuilder is a higher-order function, one which takes a function as its input and returns a function as its output; its purpose is to “promote” a HashFunction to a CredentialsBuilder . This suggests a general pattern where we define types and functions between types using type aliases, and use higher-order functions to compose function types together.

Here’s an example, where we build up logically towards a function that is able to build credentials out of validated input from any “IO Context” that knows how to issue a prompt and receive an answer. First of all we define an IOContext :

// An IOContext enables us to issue a prompt, and receive an input. typealias Prompt = String typealias Input = String typealias IOContext = (Prompt) -> Input // An IOContext that reads from the console val consoleIOContext: IOContext = { prompt -> print("${prompt}: > ") readLine()!! }

Next, we stir in validation:

// A Validator checks that an input matches an expected type. typealias Validator = (Input) -> T // An InputReader obtains a value from an IOContext. typealias InputReader = (IOContext) -> T // A validatingInputReader uses a validator on the raw input from an IOContext to return the desired type. fun validatingInputReader(prompt: Prompt, validator: Validator ): InputReader = { iocontext -> validator(iocontext(prompt)) }

Now we can define input readers for usernames and passwords:

val usernameValidator: Validator = { it } // TODO: throw an exception if the input is invalid val passwordValidator: Validator = { it } val usernameInputReader: InputReader = validatingInputReader("Enter username", usernameValidator) val passwordInputReader: InputReader = validatingInputReader("Enter password", passwordValidator)

The final step is to assemble these together into an input reader for credentials. Here, we introduce an extension function on InputReader , combine , to reduce clutter a bit :

This is a significant departure from the interfaces-and-concrete-classes style of program composition familiar from Java (and, in particular, Java with Spring). The individual pieces are all extremely small, and the logic of how they are combined is expressed through type aliases and higher-order functions. Both the domain of the program and its operations are captured in type aliases, which can make it easy to see at a glance what the program does. Following this approach, we can use type aliases to provide a kind of module-level specification of the behaviour of regions of code.

Extension functions revisited

We can see here that type aliases provide a bridge between general purpose types, such as String or (String) -> Int , and specialised types, such as explicitly-defined interfaces and classes. If a class is merely a “wrapper” of some value, then we can replace it with a type alias and extension methods, so that:

class UserRegistry(val users: Map ) { fun getUser(id: UserId): User? = users[id] fun addUser(id: UserId, user: User): UserRegistry = UserRegistry(users + (id to user)) } val registry = UserRegistry(mapOf("user1" to user1, "user2" to user2))

becomes:

typealias UserRegistry = Map fun UserRegistry.getUser(id: UserId) = this[id] fun UserRegistry.addUser(id: UserId, user: User): UserRegistry = this + (id to user) val registry = mapOf("user1" to user1, "user2" to user2)

If a class provides a single behaviour, we can replace it by aliasing a function type:

typealias UserRegistry = (UserId) -> User? fun userRegistryOf(vararg pairs: Pair ): UserRegistry = mapOf(*pairs).let{ map -> { map[it] } } val registry = userRegistryOf("user1" to user1, "user2" to user2)

Here, however, we cannot so easily wire in an addUser function, because there is no guarantee that a UserRegistry will be backed by a map – an extension function will have no access to its internals. Here’s a possible implementation (with some obvious performance limitations):

fun UserRegistry.merge(other: UserRegistry): UserRegistry = { id -> this[id] ?: other[id] } fun UserRegistry.addUser(id: UserId, user: User): UserRegistry = this.merge(userRegistryOf(id to user))

This approach can be useful if you want to pass general-purpose types in and out of a module of your program, but assign them a special meaning and associate special functionality with them internally – it eliminates a layer of “wrapping” and “unwrapping” at the API boundary.

Managing change

Suppose we decided that we wanted to store a password hash as an Array rather than a String . By changing the typealias which defines PasswordHash , we can immediately update all of the other types which depend on it. Similarly, provided we have defined Prompt as an alias of String , we can replace a monolingual prompt with an internationalised one:

typealias Prompt = Map

Equally, we can replace a type alias with a fully-fledged class or interface when the type takes on more structure. Suppose we move from a multi-user system to a multi-tenanted one, such that each user’s identity also carries with it the identity of the tenant who manages the user. We might then move from typealias UserId = UUID to:

typealias TenantId = UUID data class UserId(val tenantId: TenantId, val userId: UUID)

If we had simply used UUID as our type for user ids, then the declared type of UserRegistry would have to change from (UUID) -> User to (UserId) -> User . Because the type was aliased, however, no change is necessary. Any piece of code that simple passes a UserId through without inspecting or modifying it will continue to function without alteration.

Conclusion

I hope I have shown a range of possible uses for type aliases, ranging from gentle semantic colouring (and elimination of “stringly-typed” code) to support for a highly functional approach to structuring programs. How far you want to take it in your own code is up to you, but the advantages of type aliasing for managing changes in the types handled by a program are clear. Extension methods defined on type aliases are powerful but dangerous (or, if you prefer, dangerous but powerful). Used with care, however, they can make programs more rather than less readable and maintainable. It’s a good idea to play around with the techniques demonstrated here, and get a feel for what makes sense in your environment and what doesn’t, before trying to decide what “best practice” might be.