Data Classes in TypeScript

Published: Jun 23, 2025

Last updated: Jun 23, 2025

This post considers the concept of "data classes" as defined with Kotlin and focuses on some of the type benefits we might get within TypeScript with tagged/discriminant unions.

Please note that this is a very loose use of the term and it certainly does not plan on focuses on the methods that come with data classes in Kotlin.

What are data classes?

As defined in the Kotlin documentation, "Data classes ... are primarily used to hold data. For each data class, the compiler automatically generates additional member functions ... Data classes are marked with data".

So for an example definition in Kotlin, you could define a data class with data class User(val name: String, val age: Int).

As mentioned, I'm not too focused on the additional methods that come with data classes. There already is another library alexeyraspopov/dataclass that enables something like this.

What I am interested in is the type properties as it relates to TypeScript that come with the idea of storing data in classes that are, by designed, not meant to add any additional functionality.

I want to explore the idea of using these data containers as classes.

How can these help in TypeScript?

TypeScript is a structural type system as opposed to a nominal type system.

For an example of what I mean by structural type, take the following:

interface User { name: string; } function uppercaseUserName(user: User) { return user.name.toUpperCase(); } const obj = { name: "something", }; console.log(uppercaseUserName(obj));

In the above code, there are no type errors even though our obj is not of type User. TypeScript doesn't complain because it satifies the expected structure of a User interface.

In fact, even if we add more properties to obj, it still won't complain:

interface User { name: string; } function uppercaseUserName(user: User) { return user.name.toUpperCase(); } const obj = { name: "something", age: 22, address: "123 fake street", }; console.log(uppercaseUserName(obj));

This is in contrast to a nominal type system where it would expect the input to be of type User.

There are some reasons TypeScript opted for structural typing that is out-the-scope of this blog post.

Let's say in fact that we do want to ensure that the type matches, we could do a little tinkering to refactor the above to the following:

class DataUser { public name: string; constructor(name: string) { this.name = name; } } function uppercaseUserName(user: DataUser) { return user.name.toUpperCase(); } const obj = { name: "something", age: 22, address: "123 fake street", }; const user = new DataUser(obj.name); console.log(uppercaseUserName(user));

Note: I personally write the more verbose constructor declaration out instead of `constructor(public name: string) `. Even though it is far more succint, it is not enabled under `--erasableSyntaxOnly` in TypeScript as it transforms that signature into the more verbose version through code generation.

In the above code, we now explicitly create a DataUser instance. Note that the properties are public which is by design. The mental model you can use is that we are effectively encasing the object we want within a class.

This also looks to be similar to how Kotlin goes about this:

val jane = User("Jane", 35) val (name, age) = jane println("$name, $age years of age") // Jane, 35 years of age

In TypeScript, some of the benefits that we get so far is that because classes themselves are a type, we don't need to declare a separate interface for these. The classes type themselves. On large-scale databases where you have many different shapes of objects being passed around, this can also be blessing for code volume reduction.

Getting benefits from discriminants

We can extend these benefits by introducing a discriminant into the classes:

class DataUser { readonly _tag = "DataUser"; public name: string; constructor(name: string) { this.name = name; } }

In the above code, we've now added readonly _tag = "DataUser". But what's the value of this?

Adding a type "discriminant", also know as a "tag", is what enables the type narrowing capabilities for disciminant/tagged unions in TypeScript.

Take this contrived code example:

interface User { name: string; } interface Address { address: string; } function getUserOrAddress({ name, address, }: { name: string; address: string; }): User | Address { if (Math.random() > 0.5) { return { name, }; } return { address, }; } const obj = { name: "something", age: 22, address: "123 fake", }; const result = getUserOrAddress(obj);

In the above code, we get no type errors due to structural typing by the return type of result is User | Address.

If we start a new line with result., you'll notice that we don't get any type completion. The reason for this is that we need to first narrow down the type in order for TypeScript to be smart enough to know what to do.

In order to do this at the moment is a little shaky and specific to the output type:

if ("name" in result) { result.name; } else if ("address" in result) { result.address; }

Using this helper will assist TypeScript in knowing what types we are dealing with. Inside the 'name' in result block, TypeScript now knows that the type must be a User, while in the other if block it knows that it must be Address so we get our type completion assistance back.

As mentioned before, this is clunky and very specific to the situation. If we add another one for age, we need the same 'age' in result helper and this will change for other functions that can return different types.

This is where tagged unions come to the rescue:

interface User { _tag: "User"; name: string; } interface Address { _tag: "Address"; address: string; } function getUserOrAddress({ name, address, }: { name: string; address: string; }): User | Address { if (Math.random() > 0.5) { return { _tag: "User", name, }; } return { _tag: "Address", address, }; } const obj = { name: "something", age: 22, address: "123 fake", }; const result = getUserOrAddress(obj); switch (result._tag) { case "User": console.log("is user"); break; case "Address": console.log("is address"); }

In our interfaces now, _tag is our disciminant. Because our union type all includes _tag, our result response knows that it has that one common property between all possible return types.

We can use this to our advantage. In the switch block, we switch on the _tag property and, because TypeScript is smart enough to know all possible disciminant values, is can help us autocomplete and narrow does the types that way.

The beauty of this approach is that if you set a common disciminant across your data, you can always rely on this approach to help you narrow types down.

For what it's worth, your disciminant property does not need to be `_tag`. I've taken this from Effect TS but given that they are literally called tagged/discriminant unions, I think the naming is apt and the underscore prefix helps to highlight it as "metadata".

Applying discriminants to our data classes

Let's convert our current code example back to using "data" classes:

class DataUser { readonly _tag = "DataUser"; public name: string; constructor(name: string) { this.name = name; } } class DataAddress { readonly _tag = "DataAddress"; public name: string; constructor(name: string) { this.name = name; } } function getUserOrAddress({ name, address, }: { name: string; address: string; }): DataUser | DataAddress { if (Math.random() > 0.5) { return new DataUser(name); } return new DataAddress(address); } const obj = { name: "something", age: 22, address: "123 fake", }; const result = getUserOrAddress(obj); switch (result._tag) { case "DataUser": console.log("is user"); break; case "DataAddress": console.log("is address"); }

We rewrite the classes to have a readonly _tag property which, assuming we keep this unique across our codebase, can now always be used as a discriminant for type narrowing.

This also simplies the return values to be the creation of the data class instances.

Of course, readonly _tag is something that needs to be enforced at a behavioural level. You can of course assign this to an interface/class and implement/inherit the requirement for this property, but it feels like too much overhead to me.

You should know pretty quickly if someone hasn't added a readonly _tag property if your TypeScript types widen. Correcting this also should be simple enough.

An opinion on classes and design patterns

Something I am very bullish on is avoiding premature abstraction. It's one of those things I keep close to my heart because I've always been the perpetrator for it and burned myself.

I personally believe that object-orientated design patterns can be the enemy for this if you do not stay vigilant.

The reason that I give this "data container" a pass (at least as I currently mull over them) is purely because of the lack of functionality they provide.

Other than defining the shape of the object I want to work with, there is no ping ponging back and forth between a business layer and data class. It feels like it can give the same level of declarativeness that you get from assigning objects inline (hopefully I am conveying what I mean on this well enough).

Conclusion

This post speaks to the idea of containing and passing your key objects around using a "data class".

It can work well with the structural nature of the TypeScript type system and using a common discriminant across the codebase can you simplify your control flow.

Photo credit: josholalde

Personal image

Dennis O'Keeffe

Byron Bay, Australia

Dennis O'Keeffe

2020-present Dennis O'Keeffe.

All Rights Reserved.