Making sense of TypeScript using set theory
blog.thoughtspile.tech
blog.thoughtspile.tech
`any` can also be an indicator of the exact opposite.
“any” can be any possible type
“unknown” is a specific stand-in type. It does not represent all types but is a subset of all types, which allows you to narrow its definition from nothing.
Unknown in practice is closer to a generic in terms of usage and semantics than “any”. It’s like a generic generic except it’s not useful until you direct it. It basically lets you kick the can down the road in your code of having to make a type decision, which is very useful for writing composable and uncoupled code.
type Output<Fn> = Fn extends ((a: unknown) => infer Out) ? Out : never;
I’m not a TypeScript dev so I apologize for the stupid question. Is there any functional difference between the two? I.e. is there a case where using Unknown instead of Any will result in some sort of “compile” time error opposed to a runtime?
Some of that happens "automatically" at this point in the large number of ways that types can now be narrowed implicitly in Typescript (type guards [library functions], type asserts, the `typeof` runtime operator, the `in` runtime operator [as of recently], etc), so it can feel like `unknown`/`any` are the same up to a point, that point being where the runtime type is trivially known based on if statements and library functions around your use of the type.
(Fun fact: `any` predates most type narrowing and `unknown` by several major Typescript versions. So `any` beyond just being the final "escape hatch" from type checking is also something of a legacy tool.)
https://www.typescriptlang.org/play?#code/GYVwdgxgLglg9mABAd...
let danger: any;
// These all compile:
danger(), 9 / danger, danger.access, danger.map(x => x \* 2)
let safe: unknown;
// These all explode in TS:
safe(), 9 / safe, safe.access, safe.map(x => x \* 2) obj.value
Will return a type error that value doesn’t exist on unknown. While calling it on an ant is valid typingIt's better to say `unknown` is a promise that you won't care about the type. When you write `obj.value`, you're caring about the type. TypeScript sees that and says "hey, you promised you wouldn't care about this type."
> While calling it on an ant is valid typing
It's still invalid, same as if you'd used `unknown`. It just suppresses the type error. The presence of `any` in a codebase is a very real risk. The only reason it exists is to allow incremental porting from JavaScript to TypeScript.
And you might read that and thing "but that's crazy, its not possible for a value to be an instance of everything!" and you'd be right - the `any` type in TS is in fact crazy and you should not use it.
> Subtype of type A is a subset of type A. Supertype is a superset. Easy.
Subtype of type A is actually a superset of type A, since it contains at least all the properties of A.
If you had (contrived example) a Dog class that inherited from an Animal class, Dog would be a subtype of Animal, but its additional properties (say, a bark() method) mean that it actually has a superset of the properties in Animal. And vice versa: Animal is a supertype of Dog, but it's a subset because it contains only the properties that Dog inherits.
The set of properties of objects and the sets of objects themselves have a complementary relationship when it comes to union / intersection and subset / superset.
Let's define a "property" as being a predicate that is true for all elements of a set. For example, a collection of red objects has the "red" property.
A subset of a set of objects can only have the same or more properties than the set you started with. If you take a bunch of marbles of varying colors, selecting a subset of those marbles can yield a set with a new property (such as them all being red), but they are guaranteed to have the "marble" property.
A superset of objects can only have the same or fewer properties than the set you started with. Adding marbles to an existing set of marbles can only make "they are all red" become false (if it was true to begin with).
Union and intersection have the same duality; the union of two sets has the intersection of its properties, and the intersection of two sets has the union of its properties.
These results come from logic, not TypeScript.
interface Point2D {
x: number
y: number
}
And then I can do interface Point3D extends Point2D {
z: number
}
And that "extends" really throws off many people because in reality the type of Point2D is implicitly {
x: number
y: number
[anything_else in string]: unknown
}
So Point3D actually narrows the type of Point2D even though the notation suggests that it widens it. Fortunately, the type notation is less misleading because I can do type Point3D = Point2D & {
z: number
}
Without any misleading "extensions".It seems like it all stems from the tight coupling between types and classes in a typical OOP paradigm. When you create a class Animal you create a type Animal that should conform to specific rules, but when you instantiate that class you create an object that conforms to a narrower set of rules. E.g. an object instantiated from class Animal won't be able to woof() even though the type "Animal" easily allows it. This leads to the mental model of "extending" base class with new fields and methods. And even though it indeed extends the class Animal, it narrows the type Animal.
Perhaps it is confusing that TypeScript uses terms from both set-theory (union, intersection) and OO (extends, implements). But there is no contradiction.
The notation suggests that it extends the definition with additional restrictions. Which it does. Which, yes, produces a narrower scope. That’s how definitions work.
Usually in OOP you extend a class with additional implementation. Intersection of types means union of their members and vice versa. Getting into a situation where extending X gives you a sub-X seems like an unfortunate case of mixing two mental models that behave in a dual manner.
Universal properties are preserved "downwards" i.e. in subsets (more properly, substructures). Existential properties are preserved upwards, i.e. in supersets (extensions).
For example, the property "there exists an element that equals itself when added to itself" is preserved in a superset.
If you have sentences that mix quantifiers (e.g. "for all epsilon, there is a delta ..." or even just "every element has an inverse"), then all bets are off.
The branch of mathematics where this is studied (and rigorously proven) is model theory.
edit: I guess by "property" (given the context of the discussion) you might just mean "function" or "method", in which case, yes, a function defined on a set is also defined on a subset but not necessarily vice versa, so there are in a certain sense "more" functions defined on the subset.
That's not strictly true though. `a<b,c>(d)` is valid Javascript but Typescript treats it as a different syntactic construct[0].
[0] https://www.typescriptlang.org/play?#code/C4TwDgpgBARlC8UB2B...
class b {}
class c {}
const d = JSON.parse
const a = Promise.prototype.then.bind(Promise.resolve(1))
console.log(a<b,c>(d))
// TS output: Promise { <state>: "pending" }
// JS output: false false
TS playground: https://tsplay.dev/mAdeZNedit: there is indeed an edge case with the parentheses that throws the TypeScript parser off even if only values are involved.
This is different than JSX or hashbang, where the set of non-JS syntax cannot legally overlap with existing syntax/semantics.
- typescript is a superset of javascript
- superset of objects can only have the same or fewer properties
- Typescript contains all the rules of Javascript, and then adds some more
Do you see where the confusion is coming from?
> that also means the language specification itself is a strict superset of Javascript
This is where I disagree.
TS as a language has more properties than JS, but less rules. I.e. you can create Javascript language out of Typescript by adding more rules (constraints).
But TS as a spec has more rules than JS spec.
TS as a language is a superset of JS language. TS as a spec is a rough subset of JS spec.
For types, just draw the Venn diagrams:
- The set of objects of Type A is entirely within the set of objects of Subtype of A. Subtype of A is the superset.
- the set of things that are Dogs (class or real world) is entirely within the set of things that are Animals.
- the set of things that are Javascript programs is entirely within the set of things that are Typescript programs.
Recursive explanations are useless: "types are just sets defined by types". That's why we are trying to define them through other means.
Also TS has structural type system, which is literally about comparing properties.
What is recursive about the parent comment?
This is potentially misleading. A superset will include more objects and therefore may include more individual properties. But type checking is about what can be safely assumed about all members of a set, so more different objects in the set will constrain the type more.
Object is a superset of Date because the set of objects includes alle Dates but also things which are not dates and have different properties. But in the context of type checking, object is more constrained because there are fewer properties which all members of the set are guaranteed to have.
Typescript is a superset of Javascript because all Javascript programs are also Typescript programs. There is no contradiction.
In my mind it's the opposite: object is less constrained, because there are fewer requirements you need to fulfill to be considered an object. Object type is very permissive.
The more constrained type is, the fewer objects it's set will contain. The more constrained the type is, the more rules it enforces. Seems intuitive, isn't it?
> TS as a language is a superset of JS language. TS as a spec is a rough subset of JS spec.
This does not make any sense. A spec which fully describes TypeScript including runtime behavior would include the JavaScript spec and therefore be a superset. The TypeScript spec itself is in no way a subset of the JS spec, sine it descries features and syntax which does not exist in vanilla JavaScript.
Code is an instance of Language (and in structural typing land it can be compatible with multiple languages). Language is an instance (implementation) of Spec.
> sine it descries features and syntax which does not exist in vanilla JavaScript
That's exactly why TS spec is a subset. Take example from the article:
type B = true extends boolean ? 1 : 0; // 1
Type B is a subset of boolean, meaning there are less objects in the world that satisfy type B.There are less language implementations in the world that satisfy TS spec than JS spec. Every language that satisfies TS spec also satisfies JS spec. TS spec adds more constraints (requirements) to language implementation compared to JS spec, it is more strict, therefore it's a subset.
(Although in reality the Typescript implementation is a preprocessor, intended to use together with a regular JavaScript engine.)
{foo: number, bar: string} as type is a subset of both {foo: number} and {bar: string}. It requires it's members to have both properties. You can construct this type using intersection:
type Foo = {foo: number}
type Bar = {bar: string}
type FooBar = foo & bar
{foo: 1, bar: 'hello'} as object is a superset of {foo: 1} and {bar: 'hello'}. It contains both properties. We can construct this object using union (I use pipe instead of spread to illustrate the idea): let foo = {foo: 1}
let bar = {bar: 'hello'}
let foobar = foo | bar
When I'm saying TS is a superset of JS, I mean it in the object sense. It has all the properties of JS, and some more. All JS programs are also TS programs, but not vice versa. There's more JS programs in the world than TS programs.When I'm saying TS spec is a subset of JS spec, I mean it in a type sense. Language is an instance of a spec. TS spec has all of the requirements of JS spec, and some more. All TS implementations (languages) will contain JS implementations, but not vice versa. There's more JS implementations in the world, than TS implementations (assuming you can build TS as compiler/interpreter instead of transpiler).
I think you are confusing terminology, because those two statements are contradictory.
The subset is {red}.
Can't every possible property we can conceive of a subset be constructed in a similar fashion such that the superset contains at least the same number of elements and never less?
What do you mean? A superset always contains at least the same number of elements as a subset, by definition. A superset generally has fewer or at least weaker properties; "colour is one of A, B or C" is a weaker property than "colour is A", and there's no real difference between that and saying the superset doesn't have the property at all (I could say "my subset consists of shiny marbles; my subset has the property that they're shiny and my superset does not have that property", or I could say "my subset has the property that shininess is true, and my superset has the property that shininess is true or false", and there isn't really any difference once you get down to the set-theoretic level - a property is just a boolean-valued function). The only superset that has exactly the same properties as the set itself is the set itself (since "is a member of the subset" is a valid property).
You've mentioned that it is generally true, which seems like a better way to describe the concept.
-- edit: I understand now, in my example above the subset must contain a property by which it could be identified. The original rainbow property is lost but a new one is gained.
You're mixing levels. The property is something satisfied by members of the set, not by the set as a whole.
* Define O as a set of possible (finite) objects
* Define properties as functions P: O -> {0, 1}
* A given property then is a function of the form p(o) = 1 for all o in O that satisfy the property p.
You can use property functions to generate subsets of O ({ o for o in O where p(o) = 1 }). The intersection of these generated subsets of multiple property functions will be strict subsets (strictly "smaller" as O is finite) of O as long as at least one property function is not of the form P(o) = 1 for * in O. Generally assuming that each property function generates strict subsets, applying more properties derives a smaller intersection set. That's all.
My understanding from your stricter definition is that (by definition) all potential properties (o for o in O where p(o) = 1) must be contained within O.
If the answer is that the superset O has fewer properties where every p(o) = 1 then I agree, and I was overthinking it
It's just this honestly. A superset will generally have fewer properties which apply across the whole set.
Try this one: A is subset of B iff all the values that belong to A also belong to B. In your example (and in real life), every dog is an animal, so dogs are a subset of animals.
"at least all the properties of A" means "all of the constraints of A and some more". Subtype of A is more narrow than A, even if it means that object of subtype A has to have more fields.
I think it is correct.
Example:
type A = { a: number }
type B = { a: number, b: number }
B is not a superset of A, it's a subset and subtype of A. You can use B every time you want to use A.
In other words, of all types that can be assigned to A or used in functions and types expecting an A, B is one of those subsets.
type C = { a: number, c: number }
is another subtype of A.
A "subtype" C can be substituted in place of its "supertype" A.
Both C and B are subsets of A, in other words: in the infinite set of values of type A, some can be grouped in a type B or C. C itself is a subtype of A.
hbrn has it right calling those properties constraints, not features. With anything but a sealed class, the properties that are not present are unspecified rather than missing.
And indeed sealed classes feel very "un-set-theoretic" to me.
So I actually would agree that your original statement is valid, assuming that you refer to “types” as “classes” in the sense of Java class hierarchies.
I agree, it is unnatural to think of a subset as "extending" the set it is part of.
It happens that `A extends B` introduces a subset relation between them, but it's not correct to describe it as the same as the subset relation.
Because 'extends' really means 'is assignable to'.
I feel like most of the questions from the post might be answered fairly easily by using reasoning of 'is assignable to'.
Things assignable to A&B are things assignable to both A and B.
Things assignable to A|B are things assignable to A or to B.
'never' means a type that nothing is assignable to and is assignable to everything (bit weird because it usually doesn't have any values but you can create value of type never with the use of 'as').
'unknown' means a type that everything can be assigned to but it's not assignable to anything (except itself and any).
'any' means a type that everything is assignable to and that is assignable to everything (except never).
Ok. Ok. {} types is bit weird way to denote interfaces. :-)
Edit: besides, it's quite unintuitve that "never" is assignable to anything. How can never be something?
Only if you're happy to conflate members and sets, which is pretty confusing IMO. If I write a = b I don't generally think of b as being a set, even though in some sense it is; "b is an expression with this type" is a much more natural way of thinking to me.
Yeah, at first I was mislead by this.
Initially I thought never is opposite of any while, it turns out, it's opposite of unknown. And any is just super weird, dynamic wildcard thing that is as large or as small as needed for any given operation (but not as small as never).
Not really a paradox.
- `unknown` is the intersection (&) of all types, including an internal one that has no shape or characteristics. `unknown | {int: number}` should be `{int: number}`. `unknown & T` is always `unknown`, any characteristic T has will be discarded by the intersection.
- `any` is the union (|) of all types, including that internal one that can be anything, and yes, `unknown` too. `(any & {int: number})` should be `{int: number}`. also, `const x: any = 5 as unknown`. Union with `any` should always produce `any`.
‘Any’ does not really fit into a set theoretical model because it can be assigned to anything and anything can be assigned to it, so it is both a supertype of anything and a subtype of anything. Basically it just disables type checking.
The intersection of all types is never, because, say, number and string don't intersect. The union of all types is unknown.
any doesn't show set-like properties and yields ternary logic in clauses like any extends T, which makes it a paradox.
There's probably another "asterisk" for this, but the other thing is how TS in Node by default won't allow `require` (vanilla NodeJS), only `import` (ESM).
e.g.,
foo :: Int -> 3::Int | 4::Int
foo 4 = 4
foo _ = 3
bar :: 3::Int | 4::Int -> Bool
bar 4 = True
bar 3 = False
-- (bar 12) is a compiler error, and no need to handle other patterns
baz :: 3::Int | 2::Int -> Bool
bar 3 = True
bar x = False -- Type of x is 2::Int
Haskell has far better type inference than Typescript in large part because it doesn't have subtyping.
There are libraries for open records and sums (e.g. https://hackage.haskell.org/package/vinyl) but they're almost always the wrong choice.
That's a bit like saying "Go has a faster compiler because the language is so simple". It's true, but there's a genuine tradeoff here.
Haskellers have convinced themselves that subtypes are not worth it - and they may be right in many cases - but subtyping is a quite natural way of modeling many concepts (for example: mathematical expressions). Some of that can be recovered by typeclasses, existential types and other machinery (or you could go as far as dependent types), but that seems all more cumbersome and hard to understand than the way subtyping works in languages that support it out of the box.
Not entirely related to the post, but what app did you use to create your diagrams? They look fantastic.
I use excalidraw: https://excalidraw.com/
I think most web-dev bloggers use it, makes me feel very boring