Typing Unknown Objects in TypeScript With Record Types

Lloyd Atkinson
Using the record type to represent objects with currently unknown property names
Using the record type to represent objects with currently unknown property names

Background

When I was asked to review an unfamiliar codebase and make some changes to it in accordance with changing requirements, I came across some code that was not taking full advantage of TypeScript. At best, it was surface level usage of TypeScript without the benefits. In particular, it was not using TypeScript features for strongly typed object access. I wrote this article mainly as a way for me to share the concept of record types with other developers.

For reference, this is how an object could be created in JavaScript:

const foo = { bar: true, baz: false };

The code is obviously correct TypeScript too. But you may wish to declare a type first when using TypeScript. Note that type or interface can be used for this example - it’s probably worth reading this page of the documentation if you’re not familiar with type versus interface.

type Foo = { bar: boolean, baz: boolean };
const foo: Foo = { bar: true, baz: false };

But what happens when you, the developer, do not know the properties ahead of time? How do you write code that lets TypeScript know you’re expecting an object with properties but you don’t yet know what the names of those properties are going to be? This is a fairly common scenario when designing libraries and frameworks that defer naming to the developer using the library.

Here’s a hint. The solution is not to fallback to simply throwing Object, any, or {} at the problem thus losing all type safety.

Introducing the Record type

The problem code I encountered was written in a similar fashion to the following code. I’ve made some minor changes for clarity.

export interface FeatureSwitchItem {
name: string;
isOn?: boolean;
}
export const Features = {
general: { name: 'Lorem.Ipsum.General' } as FeatureSwitchItem,
settings: { name: 'Dolor.Sit.Settings' } as FeatureSwitchItem,
user: { name: 'Amet.Elit.User' } as FeatureSwitchItem,
time: { name: 'Etiam.Neque.Time' } as FeatureSwitchItem,
};

There are a some problems with this code. The first being the obnoxious amount of casts and the second being the lack of type declared for the Features object itself. I’ll focus on the casts first and then explain why they are not needed here and, in this instance, give a false sense of security.

The code still compiles if the casts are removed. This is an indicator these casts are useless and don’t provide any immediate benefit. It’s likely that when new properties are added to the object the cast is forgotten and thus the following code is valid when it definitely shouldn’t be. Needing to manually specify the type every time is a code smell.

{ name: 'Lorem.Ipsum.General', isOn: 'apples' },

Stay up to date

Subscribe to my newsletter to stay up to date on my articles and projects

It should probably never be updated from within the application. The exported Features object was available throughout the entire application and is open to changes from anywhere that imports it. This is a really poor practice for such configuration. By using either the readonly keyword on each property or the Readonly<T> utility type on the object itself or via an as const assertion we can inform TypeScript that this is not intended to change. Attempting to change it will result in an error. As an advocate for functional programming, I like this a lot.

A specific type for the object would remove the “need” for the casts. We can let TypeScript know the type beforehand. Not only do we get type safety but we’ll get intellisense/autocomplete support too. If we typed the cast after the object expression, we’d have missed out on this feature.

Known properties

The first solution for properties you know ahead of time would be to specify every property in the type. There’s nothing special about this and the code should not be surprising. This is ideal for situations in which a set of properties are known and therefore not flexible - which is fine for most situations.

type Configuration = {
readonly connectionString: string;
readonly timeoutInMilliseconds: number;
};

Unknown properties

So, how do we approach being able to specify what type a property should be, without also knowing the name of that property? Record types! Here is the refactored version of the original code.

interface FeatureSwitchItem {
readonly name: string;
readonly enabled: boolean;
}
type FeatureToggles = Readonly<Record<string, FeatureSwitchItem>>;
const features: FeatureToggles = Object.freeze({
general: { name: 'Lorem.Ipsum.General', enabled: false },
settings: { name: 'Dolor.Sit.Settings', enabled: true },
user: { name: 'Amet.Elit.User', enabled: false },
time: { name: 'Etiam.Neque.Time', enabled: true },
});

Try update the properties in the TypeScript playground and note the error messages.

Conclusion

  • I’ve introduced a type for the feature switches that allows for properties of any name
  • Ensured that no redundant casts are required
  • Renamed the isOn property to what I consider a clearer name - I do like is and has prefixes but for this property “is on” sounded borderline tautological
  • The new enabled property is no longer optional - optional booleans are confusing at the best of times as some software treats false and missing values differently while others do not and I felt that for cross cutting concerns like configuration and feature switches any cognitive load should be reduced
  • Avoided any horrible Object, {}, any nonsense
  • Enforced readonly/immutable semantics both at build time and runtime
    • If you trust the consumers of the code to not try break the design at runtime then the Object.freeze() is not necessary

If you liked this TypeScript article and would like to read more, I highly recommend you read another article I have on some more advanced TypeScript concepts! It’s called Going Further With TypeScript - Part 1: Mapped Types.

Share:

Spotted a typo or want to leave a comment? Send feedback

Stay up to date

Subscribe to my newsletter to stay up to date on my articles and projects

© Lloyd Atkinson 2024 ✌

I'm available for work 💡