question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Strawperson TypeScript integration for Record & Tuple

See original GitHub issue

Strawperson TypeScript integration for Record & Tuple

🔍 Search Terms

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn’t be a breaking change in existing TypeScript/JavaScript code
  • This wouldn’t change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn’t a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript’s Design Goals.

📃 Motivating Example

We can use and type records to be used as a coordinate system:

type Coordinate = #{ lat: number, lon: number };
const boaty = { name: "Boaty McBoatface" };
const coords: Coordinate = #{ lat: 13.18653, lon: -24.78303 };
const boats = new Map<Coordinate, any>([[coords, boaty], /* ... */]);


assert(boats.get(coords) === boaty); // OK ✅
const coordsCopy: Coordinate = #{ ...coords };
assert(boats.get(coordsCopy) === boaty); // OK ✅

💻 Use Cases

This change will not only make TS compliant with ECMA262 once Record & Tuple lands in the spec but it will add type representations that will make Record & Tuple even more ergonomic to use.

High quality TS support for Record & Tuple is essential for it to be widely adopted. The feature is more useful if it is backed by a good type system.

Strongly-typed immutable data structures aid writing correct and reliable programs, so Record & Tuple in TypeScript is an excellent fit!

⭐ Suggestion

The champion group for Record & Tuple is going to ask for Stage 3 soon and we want to have a solid TS integration plan before going forward with Stage 3.

The goal of this issue is to propose a strawperson mechanism in TS to handle the proposed Record & Tuple.

This is based on what @rbuckton suggested in the proposal’s repo.

Step 0: Disambiguate existing TS Records & TS Tuples

Before any work is made into integrating Record & Tuple into TS, it would be useful to disambiguate existing TS Records & TS Tuples from ES Records & ES Tuples. This is unfortunately going to be a source of confusion and it might be useful to start going ahead of this problem.

The Record<K,V> utility type

As TS, like JS, is staying backwards compatible, Record<K,V> is here to stay and is not going anywhere.

We would like to however make it an alias of another more precisely named type and then softly deprecate Record:

type Dictionary<K extends string | number | symbol, T> = { [P in K]: T; };

/**
 * @deprecated Use `Dictionary` instead of `Record`
 */
type Record<K extends string | number | symbol, T> = Dictionary<K, T>;

Dictionary was sugggested to us by @rauschma in a R&T issue: https://github.com/tc39/proposal-record-tuple/issues/82#issuecomment-1135747127

Naming is fully up for debate.

The TS Tuples

TS Tuples being a syntactic construct and not a named type, it will be easier to work around in the language but also harder to change across the board as both the TS documentation and hundreds of blog articles have adopted that terminology.

The disambiguating terminology could be “Fixed Array” as sugggested to us by @rauschma in a R&T issue: https://github.com/tc39/proposal-record-tuple/issues/82#issuecomment-1135747127.

Naming is fully up for debate.

Step 1: record & tuple primitive types

Now comes the question of integrating ES Records & ES Tuples into TypeScript without introducing completely new semantics to TS.

Set of all Records or Tuples

We would introduce new record and tuple primitive types in TS. Those match the set of all records and all tuples respectively:

const rec: record = #{ bar: "baz" }; // ok
const tup: tuple = #["bar", 123]; // ok

This would match the JS runtime behavior here since those are the typeof returns for Record & Tuple:

assert(typeof #{} === "record"); // ok
assert(typeof #[] === "tuple"); // ok

Marker record primitive type

Those primitive types would act as markers on types like any other primitive type:

// the extra `readonly length: number` is useless but works today
type AString = string & { readonly length: number };

const foo: AString = "foo"; // ok

Using that principle we can get a type for a specific given record:

type Foo = record & { readonly bar: string };

const fooRec: Foo = #{ bar: "baz" }; // ok
const fooObj: Foo = { bar: "baz" }; // not ok

This will also let Records match normal interfaces:

interface Foo {
    bar: string;
}

const fooRec: Foo = #{ bar: "baz" }; // ok
const fooObj: Foo = { bar: "baz" }; // ok

fooRec.bar = "qux"; // ok for ts, will throw in engine -> don't forget `readonly`!

Marker tuple primitive type

We’ve seen records but tuples are similar:

type Foo = tuple & (readonly [string, number]);

const fooTup: Foo = #["bar", 123]; // ok
const fooArr: Foo = ["bar", 123]; // not ok

Finally we can also represent arbitrarily sized tuples:

type Foo = tuple & (readonly string[]);

const fooTup: Foo = #["bar", "baz", "qux"]; // ok
const fooArr: Foo = ["bar", "baz", "qux"]; // not ok

Record & Tuple Object constructor & wrapper types

We also need to define the following, we disambiguate with the Wrapper suffix:

// Not really necessary as `{}` is already the super of everything apart from `null | undefined`.
interface RecordWrapper {}

interface RecordConstructor {
    readonly prototype: null;
    new(value?: any): never;

    isRecord(arg: any): arg is record;
    // ...
}

declare var Record: RecordConstructor;

interface TupleWrapper<T> {
    readonly length: number;

    slice(start?: number, end?: number): tuple & (readonly T[]);
    // ...
}

interface TupleConstructor {
    new(value?: any): never;

    isTuple(arg: any): arg is tuple;
    // ...
}

declare var Tuple: TupleConstructor;

Step 2: Type syntax for Record & Tuple

To keep the feature modular to implement, we can land separately the following syntax to represent Record & Tuple:

type Foo = #{ bar: string };
// would be equivalent to:
type Foo = record & { readonly bar: string };
type Foo = #[string, number];
// would be equivalent to:
type Foo = tuple & (readonly [string, number]);
type Foo = string#[];
// would be equivalent to:
type Foo = tuple & (readonly string[]);

This is not required to properly support R&T but it should be widely used if implemented.

Complementary work: strictReadonly

The problem

Because we designed Record & Tuple to integrate well with existing JS (and therefore TS) code, we cannot make R&Ts incompatible with existing interfaces even if they are lacking readonly markers on their properties.

This has the negative consequence of TS not erroring on the following code:

interface Foo {
    bar: string;
}
function anythingCanHappenToFoo(aFoo: Foo) {
    // ...
    aFoo.bar = "I can change anything!";
    // ...
}

anythingCanHappenToFoo({ bar: "hello" }); // Typechecks OK, Runtime OK
anythingCanHappenToFoo(#{ bar: "hello" }); // Typechecks OK, Runtime TypeError

Note that the same problem already happens today with frozen objects.

We also think that not letting people plug in Records or Tuples into types that should match but are mutable would be a greater problem for Record & Tuple adoption and basically make them an “island feature” in TS that couldn’t work with existing code.

Immutability by default, even for objects: strictReadonly

The good news is that mutating object arguments are already quite a bad practice and a source of silent logic errors so they are not recommended anyway. Most reasonably modern code avoids the practice already.

Others thought of that, notably in https://github.com/microsoft/TypeScript/issues/13347#issuecomment-908789146, making all fields readonly unless opted out by a modifier via a strictReadonly option:

interface Foo {
    bar: string;
}
function anythingCanHappenToFoo(aFoo: Foo) {
    // ...
    aFoo.bar = "I can change anything!"; // Typecheck FAIL
    // ...
}

anythingCanHappenToFoo({ bar: "hello" }); // Typechecks OK
anythingCanHappenToFoo(#{ bar: "hello" }); // Typechecks OK

And at this point, mutable or writable modifiers would make interfaces reject R&Ts:

interface Foo {
    mutable bar: string;
}
function anythingCanHappenToFoo(aFoo: Foo) {
    // ...
    aFoo.bar = "I can change anything!"; // OK
    // ...
}

anythingCanHappenToFoo({ bar: "hello" }); // Typechecks OK
anythingCanHappenToFoo(#{ bar: "hello" }); // Typecheck FAIL

Many questions need to be answered as part of this (should this mutable property be “viral” to other interfaces? …) so that is why this should be considered a parallel proposal and be untied to R&T. This also might be quite a sizable feature so keeping it separate to adding a new ECMA262 feature is beneficial.


This issue was kindly reviewed by @acutmore, @dragomirtitian and @robpalme. It also contains suggestions from @acutmore, @dragomirtitian and @rauschma.

Issue Analytics

  • State:open
  • Created a year ago
  • Reactions:32
  • Comments:14 (11 by maintainers)

github_iconTop GitHub Comments

5reactions
frigus02commented, Sep 16, 2022

I tried both patches on Google’s codebase. This ran ~400k projects. Here are the results:

  • Simple patch
    • 109 broken projects, all because of typeof value === 'object' checks that don’t exclude records.
  • Complex patch
    • 42 broken projects because of typeof value === 'object' checks
    • 209 broken projects because the compiler crashed with “Maximum call stack size exceeded” errors. I can’t immediately see a common call path in the stack traces. But I can have a closer look if necessary.

Seems like a very small number of error overall. Also seems in line with what you found, @acutmore.

3reactions
acutmorecommented, Sep 17, 2022

thanks for trying out the patches @frigus02, that is much appreciated and really useful additional data.

“But I can have a closer look if necessary”

That’s OK. I think we can either take the results of the simple-patch, or assume the worst-case for the complex-patch and consider the projects that crashed as ones that would have type-errored anyway. Thanks again!

Read more comments on GitHub >

github_iconTop Results From Across the Web

Record & Tuple Tutorial - TC39
This tutorial will guide you through the Record & Tuple ECMAScript proposal. ... All JavaScript primitive values, including Record & Tuple , are...
Read more >
The JavaScript Record and Tuple proposal: An overview
Introduction. The ECMAScript Record and Tuple proposal introduces two new data structures to JavaScript: records and tuples.
Read more >
PECO: methods to enhance the privacy of DECO protocol
that enhances privacy features through the integration of two new ... We represent a certificate chain as a tuple ... and record its...
Read more >
Sept 18 TC39 Meeting Notes - ES Discuss
Should Proxy.revokable return the tuple as an array or an object? ... RH: Moving from strawman to proposal/spec is meaningful to implementor teams....
Read more >
Hands-On way to build Frontend with React | Nick Ovchinnikov
First will be a configuration of the ReactJS application with Typescript and using build ... And probably will go on to be using...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found