tidalcycles / strudel

Web-based environment for live coding algorithmic patterns, incorporating a faithful port of TidalCycles to JavaScript
https://strudel.cc/
GNU Affero General Public License v3.0
643 stars 111 forks source link

Implement type system? #483

Open yaxu opened 1 year ago

yaxu commented 1 year ago

It could be useful to implement some part of a type system, or make use of some existing library for this.

e.g. being able to infer types based on context could help with:

felixroos commented 1 year ago

resolving fractions from floating point representations immediately, to avoid performance issues like https://github.com/tidalcycles/strudel/issues/194

I thought a bit about that.. currently, the conversion happens at query time, so it will be done over and over again at the query frequency.. the conversion could be done before patternification happens. The type definitions could be part of register, like:

register('off', (t, f) => {/*..*/}, { params: ['fraction', 'function'] })

before patternification, register could then wrap Fraction around the param that comes in, meaning the conversion will only happen once at evaluation time.

I wonder what happens when the param is a pattern, likein x.off(cat(1,2), () => ..., the pattern could be fmapped, but wouldn't that mean it will be lazy evaluated at query time? If yes, it would still greatly improve performance of non pattern params

parsing values in mini-notation differently for different types as tidal does - e.g. 'e' is either parsed as a note or a fraction (in this case, eighth) depending on the inferred type

This could work similary, e.g. x.off("<e h>", ...) is transpiled to x.off(mini('<e h>'), ...) which is equivalent to x.off(cat('e','h'),...). Because register knows the first param is expected to be a Fraction it can convert via fmap.

the polymorphism we want for control patterns/functions

taking the same example, but this time focusing on the second param:

x.off(1, n(2).fast(2))

if we would want this to result in x.n(2).fast(2) we cannot do it at evaluation time, because the pattern n(2).fast(2) is already evaluated when it gets passed to off, so we cannot chain .fast(2) to the outer pattern.

As mentioned earlier, one way to solve it would be to transform the pattern at transpile time, changing it to x.off(1, x=>x.n(2).fast(2)). When the transpiler runs, all register calls have already been executed so we can have a map that contains the types of all params for each function e.g { off: ['fraction', 'function'] }. Of course, we have to be careful to prepend x=>x. only where it should be:

x.off(1, cat(n(1).fast(2), fast(4)))

here, the expected transpilation output would be

x.off(1, cat(x=>n(2).fast(2), x=>x.fast(4)))
// or
x.off(1, cat(x=>n(2).fast(2), fast(4)))

For each param of type 'function', the transpiler could traverse the call tree and prepend x=>x. wherever a control function is called (or probably also a curried function).

Generally I think it's helpful to think about the different tradeoffs between transpile time, evaluation time and query time. Everything we do at transpile time won't affect performance at runtime, but of course it only works if the transpiler is used. The polymorphism we're trying to achieve is basically syntax sugar, as we can achieve the desired outcome by explicit functions instead, so I think it's not too bad to only have it working with transpiler.

yaxu commented 1 year ago

I wonder what happens when the param is a pattern, likein x.off(cat(1,2), () => ..., the pattern could be fmapped, but wouldn't that mean it will be lazy evaluated at query time?

Unfortunately yes.

If yes, it would still greatly improve performance of non pattern params

Yes, but strudel/tidal is all about patterns.

parsing values in mini-notation differently for different types as tidal does - e.g. 'e' is either parsed as a note or a fraction (in this case, eighth) depending on the inferred type

This could work similary, e.g. x.off("<e h>", ...) is transpiled to x.off(mini('<e h>'), ...) which is equivalent to x.off(cat('e','h'),...). Because register knows the first param is expected to be a Fraction it can convert via fmap.

But howabout if mini could do the conversion as part of its parsing?

Advantages:

However for this to work, the expected type would somehow have to be passed into mini, and all the other functions along the way to it..

As mentioned earlier, one way to solve it would be to transform the pattern at transpile time

Interesting but seems a bit complicated, and not sure it's possible to fix up a pattern for every possibility..

felixroos commented 1 year ago

If yes, it would still greatly improve performance of non pattern params Yes, but strudel/tidal is all about patterns.

Of course, but still there are often params that are not patterned, which will be optimized at evaluation time then.

But howabout if mini could do the conversion as part of its parsing?

that should be possible, the transpiler would need pass the type to mini as a second param, like

note('e').off("<e h>", add(12))
// is transpiler to
note('e').off(mini('<e h>', 'fraction'), add(12))

Interesting but seems a bit complicated, and not sure it's possible to fix up a pattern for every possibility..

Maybe I'll just try it.. it could be pretty simple to implement

felixroos commented 1 year ago

it is possible to use typescript from within js:

https://www.typescriptlang.org/docs/handbook/intro-to-js-ts.html

might be worth a try

felixroos commented 1 year ago

might be interesting: https://gcanti.github.io/fp-ts/

mnvr commented 8 months ago

Here is a TypeScript definitions file that I wrote for Strudel: https://github.com/mnvr/strudel-ts

It is not a complete solution. I can't figure out if it is my TypeScript fu that is lacking, or if TypeScript is unable to express a particular type constraint, but over time the definitions in this file have evolved to use a too-generic-to-catch-errors type that looks like this:

/**
 * A pattern, or something which can be converted into a pattern.
 */
export type Patternable<T = number> =
    | Pattern
    | Pattern[]
    | string
    | string[]
    | T
    | T[]
    | (T | T[] | Pattern)[];

That's why I'd never tried to open a PR to get them into Strudel. You folks have thought much more about typing Strudel than I have, and I don't think there are easy answers, maybe Strudel is not meant to be a beast chained in types.

But, in a very day-to-day manner, these type definitions were useful to me:

  1. By adding this file to my project I get context sensitive help and autocomplete in VS Code.
  2. And I don't have to cast as any or otherwise silence the TypeScript compiler when using Strudel.

So I've kept adding to them. Today I thought maybe I should mention the existence of this file in Strudel's GitHub - there might be others in the same boat, searching for the keyword "typescript" in the issues. Using the file is simple, just add it to somewhere the Typescript compiler can see it.

Hope this helps someone! (Also, apologies for bumping up this old thread, I assumed this is where you were collecting information that'd be used to type Strudel in the future, so this felt like the correct place to mention an incomplete attempt at writing TypeScript types for it).

felixroos commented 8 months ago

@mnvr Thanks for sharing the strudel-ts lib! Looks like you've already done a chunk of work here :) I think the original intent of this issue was to implement a type system that works at runtime, so this is not something that typescript can do, so for example using different functions for different types. Javascript only knows about objects / numbers / strings etc.. but we'd need different types of patterns to be recognized at runtime, e.g. Pattern of string / number / function etc... This would require to annotate patterns or events with type information somewhere in the processing chain, to be able to apply different functions based on the type later.

For the reasons you've mentioned above, having some degree of types in the project is still a good idea, the question is to which degree. The least we could do is provide practical types for commonly used functions without typing everything (probably similar to strudel-ts). Typing the whole system is probably impossible because we are doing some crazy metaprogramming in there which I doubt can be typed with typescript. Maybe it is possible, but I don't see myself doing it + I doubt it will be easier to read. I am also not very keen to change all files to .ts and have more complexity in the build setup. The possibility of using typescript types in jsdoc comments sounds like a good compromise for me. This is also what the svelte team is doing. Maybe having a d.ts file like yours would be a good start too. I am not sure about the tradeoffs, jsdoc comments might be easier to keep in sync but they probably are a bit more annoying to write and might not support every typescript feature

mnvr commented 8 months ago

Thanks for taking a look!

If it wasn't absolutely clear, I agree with you totally, I don't think moving to ts might be the right choice for Strudel - I was just sharing this file for the other reasons. The TS in JSDoc is a better approach for Strudel, simply because, as you said, they're easier to keep in sync.

That is, already the "reference" documentation is sort of split across two places - the website reference, and the function reference. Having it be triplicated into a separate d.ts is reaching the unmaintainable territory.

I think there is still some missing bit though. It could be just misconfiguration on my part somewhere, but when I import Strudel, VS Code starts warning me about

Could not find a declaration file for module '@strudel/core'

There must be some way for us to direct tsc (or VS Code? I'm not sure which) to see that Strudel already has the TypeScript types specified in the JSDoc. It must be possible, because Svelte is doing it.

Because once tsc can pick up the parameters from the JSDoc, the need for this d.ts goes away, because I think many (almost all probably!) Strudel functions already have type annotations already in their documentation comments.

mnvr commented 8 months ago

Hah, found it! They seem to be using a build script to generate the .d.ts - svelte/scripts/generate-types.js. This seems to be using import { createBundle } from 'dts-buddy'.