TypeScript's keyof and Structural Typing
As I explore the possible solution space for designing a type for the Pyret programming language with Ben Greenman, I’ve spent some time grappling with the concept of structural type compatibility. Specifically, I am interested in TypeScript’s type system and the keyof
operator. This blog post focuses on summarizing what I’ve learned thus far (DISCLAIMER: this post might contain inaccuracies as I continue to refine my understanding).
If these concepts interest you, stay tuned. I plan to continue exploring these ideas and ultimately aim to answer questions like: can we leverage this concept within Pyret?
Type compatibility
We’ll use the term type compatible
throughout this post, so let’s take a moment to formally define it. As best summed up by Nominative and Structural Typing, type systems must be able to answer two type compatibility questions:
- equivalence: given two type expressions T1 and T2, are T1 and T2 equivalent? In other words, are all objects of type T2 valid objects of type T1, and vice versa? Commonly written as T1 == T2
- subtype: given two type expressions T1 and T2, is T1 a subtype of T2? In other words, are all objects of type T1 also objects of type T2? Commonly written as T1 <= T2
Generally, there are two main approaches to answering these questions: structural typing and nominal typing.
Structurally typed: type compatibility is determined by the structure of the types
Nominally typed: type compatibility is determined by the name of the types
Consider the following example.
foo {
bar: () => string
}
baz {
bar: () => string
}
In a nominally typed language, foo != bar
, however in a structurally typed language, foo == bar
.
What about Duck Typing? Duck typing is dynamic type compatibility at runtime; both structural and nominative typing are static. Furthermore, typically at runtime we just care if an object has a specific property more so than if an object is really some type (i.e. don’t care as much about the type’s name or entire type structure).
Which is better? Of course, it depends: one of the main pros of nominal typing is differentiation between two types of the same structure, i.e. MeasurementInFeet and MeasurementInMeters. Structural typing is advantageous for its flexibility.
While we’re comparing structural and nominal type systems, note that hybrid systems (I don’t believe this is a technical term) exist.
At a base level, C++ is nominatively typed–two classes with different names are not considered equivalent; and subtyping must be explicitly declared (via inheritance). However, the template system uses structural typing–any type can be an argument to any template; and the compiler won’t complain unless a particular instantiation of a template is incompatible with its declaration. – Nominal and Structural Typing
Here is a nice table summarizing type compatibility for a large set of programming languages: Comparison of programming languages by type system - Wikipedia.
Structural Typing
I have much more experience with nominally typed systems: C#, Python, Java, Rust (albeit a bit fuzzy), Haskell (which is mostly nominal), etc. Thus, I will focus here on structurally typed languages.
TypeScript
TypeScript is a structurally typed programming language that transpiles to Javascript allowing for engineers to gradually introduce types to an existing, dynamic, duck typed, Javascript codebase and/or leverage the expansive JavaScript ecosystem with an additional layer of safety.
Unless you’re already familiar with TypeScript’s type system, I’d recommend opening up a TS Playground online editor in another screen as you read along.
In a structurally typed language, since we care about structure, not about name, an analogous operator for .class
or .type
is keyof
(hand wavy, hand wavy), where keyof
returns a concatenated collection of object properties.
type Point = { x: number; y: number };
// "x" | "y"
type P = keyof Point;
TypeScript does have the typeof
operator, however it is limited (see more on limitations here).
type Point = { x: number; y: number };
// typeof pt = { x: number; y: number }
const pt: Point = { x: 10, y: 11 }
keyof Examples
So in what ways is keyof
useful? It may not be immediately obvious… it certainly wasn’t for me!
- (1) accessing an object in a “safe” way via generic constraints
interface Food {
isTasty: boolean;
}
let pizza = { isTasty: true }
function isMyAThing(food: Food, attribute: keyof Food): boolean {
return food[attribute];
}
isMyAThing(pizza, "isTasty")
isMyAThing(pizza, "isHealthy") // will fail
Here isMyAThing
does two interesting things: (a) indexes properties of a type (b) constrains input of attribute to just being valid properties of Food
.
- (2) map over properties of an existing type
type Boolify<T> = {
// read "for each key in T translate to boolean
[K in keyof T]: boolean
}
interface SomethingUseful {
name: string;
age: number;
}
type SomethingLessUseful = Boolify<SomethingUseful>;
let notHelpful: SomethingLessUseful = { name: true, age: false };
Here Boolify
maps all properties of the generic T
to boolean
s,
- (3) work in conjunction with utility types (i.e. Record, Pick, etc.) which manipulate the type (since its structural typing, that means manipulating the underlying properties)
Consider Record
: which constructs an object type whose property keys are Keys and whose property values are Type. This utility can be used to map the properties of a type to another type. More formally: Record<Keys, Type>
(Record Type - TypeScript Handbook)
type Qualities = {
isTasty: boolean;
isHealthy: boolean;
calories: number;
}
interface Info {
value: string
}
type Food = Record<keyof Qualities, Info>;
const asparagus: Food = {
isTasty: { value: "sometimes" },
isHealthy: { value: "yes!" },
calories: { value: "idk" }
}
- (4) create new types
type Food = {
isTasty: boolean;
}
type Decoration = {
isPretty: boolean;
}
// intersection type: combination of all properties
type DecorativeFood = Food & Decoration;
const weddingCake: DecorativeFood = {
isTasty: true,
isPretty: true,
}
// union type: one type _or_ the other
type DecorationOrFood = Food | Decoration;
const bacon: DecorationOrFood = {
isTasty: true
};
const painting: DecorationOrFood = {
isPretty: true
}
// union type and keyof
type DecorationOrFoodDescription = keyof Food | keyof Decoration;
const bacon: DecorationOrFoodDescription = 'isTasty'
const painting: DecorationOrFoodDescription = 'isPretty'
Conclusion
With a basic understanding of keyof
and structural typing, the next question is can we implement a similar keyof
operator for Pyret?
Pyret’s core is written in JavaScript. Thus, we should be able to, more-or-less, translate keyof
from TypeScript to JavaScript and implement it as a built-in method for Pyret. However this assumes that core types are implemented as objects in a fairly consistent way… if not, implementing keyof
might be a heavier lift than expected.
We’ll explore this in a following post.
References
- Structural Typing - Wikipedia
- Nominal Typing - Wikipedia
- Type compatibility - TypeScript Handbook
- Nominal and Structural Typing
- Nominal Typing Proposal TypeScript
- Comparison of Programming Languages by Type System - Wikipedia
- Using Reflection in Java
- KeyOf TypeScript
- Types vs. Interface in TypeScript
- How to use KeyOf Operator in TypeScript
- 5 ways to use
keyof
operator in TypeScript - Stackoverflow question about
extend keyof
andin keyof
- keyof and Lookup Types in TypeScript
- Mapped Types in TypeScript
- Types and Programming Languages - Benjamin C. Pierce - 19.3
- Utility Type - TypeScript Handbook
- Unions and Intersection Types - TypeScript Handbook