leopard-js / sb-edit

Javascript library for manipulating Scratch project files
MIT License
54 stars 15 forks source link

Proposal: New API + set of types for working with blocks in a type-safe way #93

Open adroitwhiz opened 1 year ago

adroitwhiz commented 1 year ago

There are a lot of issues with the current block/opcode API that we seem to keep running up against:

I propose to fix this by moving blocks' opcode + input data into runtime-accessible "type objects". Each defined block would have an immutable "block prototype" instance, which can be queried both at runtime and compile-time (since it's immutable, the TypeScript compiler can read its fields). This solves our issues nicely:

Here's some proof-of-concept code I wrote which demonstrates this approach:

Type definitions w/ demo code ```ts /** * Maps a block input interface (e.g. {type: "number", value: string | number}) to the corresponding block-prototype * input's interface (e.g. {type: "number", initial: string | number}), which defines the input's initial value. */ type ProtoInput = Input extends BlockInput.Any ? // Needed to distribute over the union, so that `type` must belong to the same union branch as `value`. // See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#distributive-conditional-types {type: Input['type'], initial: Input['value']} : never; /** * Runtime type information for a block with a certain opcode, which tells us what its inputs are and what their * default values should be. */ type BlockPrototype< OpCode extends string = string, Inputs extends {[x: string]: ProtoInput} = {[x: string]: ProtoInput} > = Readonly<{ opcode: OpCode; inputs: Inputs; }>; /** * Instance of a block, with a given opcode and inputs. */ type Block< OpCode extends string = string, Inputs extends {[x: string]: BlockInput.Any} = {[x: string]: BlockInput.Any} > = { opcode: OpCode; inputs: Inputs; }; /** * Maps a type BlockPrototype to the corresponding Block. */ type BlockForPrototype

= // Infer the prototype's opcode and default inputs P extends BlockPrototype ? Block : never; /** * Check whether a block instance is assignable to a given block prototype. * @param proto The block prototype to check against. * @param block The given block instance. * @returns true if the block has the same opcode and inputs as the block prototype */ function blockMatchesProto (proto: V, block: Block): block is BlockForPrototype { if (block.opcode !== proto.opcode) return false; for (const [inputName, inputTypeAndInitial] of Object.entries(proto.inputs)) { if (!(inputName in block.inputs)) return false; const input = block.inputs[inputName]; if (input.type !== inputTypeAndInitial.type) return false; } return true; } // We can define a block prototype, and the compiler will infer a type for it and ensure it satisfies the properties of // a BlockPrototype! const MotionMoveSteps = { opcode: 'motion_movesteps', inputs: { STEPS: { type: 'number', initial: 10 } } } as const satisfies BlockPrototype; const InvalidExample1 = { opcode: 'invalid_block', inputs: { STEPS: { type: 'number', initial: 10, // The compiler will reject this (field doesn't exist on ProtoInput) oops: 999 } } } as const satisfies BlockPrototype; const InvalidExample2 = { opcode: 'invalid_block', inputs: { // The compiler will reject this (missing "type" and "initial") STOMPS: {} } } as const satisfies BlockPrototype; const InvalidExample3 = { opcode: 'invalid_block', inputs: { STOMPS: { type: 'number', // The compiler will reject this (type `boolean` not assignable to `string | number`) initial: true } } } as const satisfies BlockPrototype; // We can guarantee that this function returns either a motion_movesteps block or nothing at all! function foo (block: Block): BlockForPrototype | null { if (blockMatchesProto(MotionMoveSteps, block)) { return block; } return null; } // We can safely access the block's inputs! function bar (block: Block): void { if (blockMatchesProto(MotionMoveSteps, block)) { // We can even tell the type and value of the inputs! const steps: string | number = block.inputs.STEPS.value; const inputType: 'number' = block.inputs.STEPS.type; console.log(steps, inputType); } } ```

towerofnix commented 1 year ago

Here's your notes from #94, for reference:

(When deserializing [sb3], null block inputs are skipped completely for now) I'll revisit this later. If there's, for instance, an empty C-block inside a .sb3 file, its SUBSTACK will be either null or completely absent in the .sb3's block inputs, depending on whether a stack was ever dragged into the C-block. We previously used to treat the two cases differently, treating the input as {type: "string", value: null} in the former case (flat-out wrong), and leaving the input out in the latter case. Now, we always leave the input out. It'll be easier to fix this properly once we have a better way to enumerate a block's intended inputs-- see https://github.com/leopard-js/sb-edit/issues/93.

towerofnix commented 1 year ago

@adroitwhiz I'm trying to understand how this issue and #100 are separate a bit better. It seems like they largely address the same areas, reworking overall API structure to improve type safeness, expand library capabilities, and tidy code style and structure legibility. I'm interested if you feel there are separate things/goals they address, or if #100 was more of a follow-up detailing a specific example of why the overall rework discussed here is necessary?

adroitwhiz commented 1 year ago

100 was something I discovered after writing this issue. I think this issue better describes my proposed solution (in retrospect)

towerofnix commented 1 year ago

Yeah, it felt more like a diagnosis of an issue via description of symptoms rather than a detailed way forward, which is mostly covered here.